Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 15 additions & 9 deletions crates/adapter-zarvis/src/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
use crate::context;
use crate::persist::{self, Persist};
use crate::provider::{self, Content, LlmProvider, Message, Role, StopReason, TextSink, ToolCall};
use crate::tools::{truncate_for_model, ToolCtx, ToolOutcome, ToolRegistry};
use crate::tools::{ToolCtx, ToolOutcome, ToolRegistry, truncate_for_model};
use agentd_protocol::adapter::{AdapterContext, AdapterInboxMsg, EventEmitter};
use agentd_protocol::{MessageRole, SessionEvent, SessionStartParams, SessionState, ToolRisk};
use anyhow::Result;
Expand Down Expand Up @@ -123,11 +123,11 @@ pub async fn auto_review_for_adapter(
// auto-approved deterministically via the auto-approve policy; this covers
// the residual cases that still reach the reviewer (e.g. shell reads or
// removals of widget files).
let widgets_hint =
match std::env::var(agentd_protocol::agent_context::ENV_SESSION_WIDGETS_DIR) {
Ok(dir) if !dir.is_empty() => format!("\n\nSession widget directory:\n{dir}"),
_ => String::new(),
};
let widgets_hint = match std::env::var(agentd_protocol::agent_context::ENV_SESSION_WIDGETS_DIR)
{
Ok(dir) if !dir.is_empty() => format!("\n\nSession widget directory:\n{dir}"),
_ => String::new(),
};
let user = format!(
"{}{widgets_hint}\n\nPending tool:\nTool: {tool}\nArgs summary:\n{args_summary}",
ctx.format_for_prompt()
Expand Down Expand Up @@ -219,7 +219,11 @@ You also have local tools (shell, edit_file, write_stdin) for quick host-level q

LONG-RUNNING TOOLS: a tool result of exactly "(running in background; will report when complete)" means the tool exceeded the foreground time budget and is still running. Don't retry it. Don't poll. Continue with whatever you can do without that result. You'll receive an `OBSERVATION:` message with the real output later — react to that observation when it arrives, ideally with a short summary or a `noted` if no action is needed.

EVENT OBSERVATIONS: messages starting with "OBSERVATION:" come from the agentd event monitor, not the user. They tell you another session in the fleet changed state (entered awaiting_input, errored, finished, or is asking for approval). For each observation, decide whether the user benefits from being notified or whether action is helpful. If neither — most cases, especially routine awaiting_input transitions — reply with exactly the single word `noted` and nothing else. If something is genuinely worth surfacing (an unexpected error, a session done with notable output, an approval request the user may have missed), give one short sentence. Never start a turn by re-stating the observation back at the user. Never invoke tools just to "check in" on a session whose state you already know from the observation.
EVENT OBSERVATIONS: messages starting with "OBSERVATION:" come from agentd, not the user. They can be fleet events or ambient loop ticks.

For fleet-event observations, decide whether the user benefits from being notified or whether action is helpful. If neither — most cases, especially routine awaiting_input transitions — reply with exactly the single word `noted` and nothing else. If something is genuinely worth surfacing (an unexpected error, a session done with notable output, an approval request the user may have missed), give one short sentence. Never start a turn by re-stating the observation back at the user. Never invoke tools just to "check in" on a session whose state you already know from the observation.

For `OBSERVATION: ambient operator loop tick`, act as an ambient companion. You may inspect fleet state, project memory, widgets, transcripts, diffs, or outputs when that would help you notice blockers, stale work, workflow issues, or opportunities to reduce user effort. Prefer updating/removing compact Operator widgets over chatting. If nothing is worth surfacing, reply exactly `noted`. Do not take risky/destructive/external actions without normal approval; ambient help is advisory and no critical user journey should rely on it.

Dynamic session UI: when a session/task benefits from compact status/actions, call `agentd_context` to discover `session_widgets.dir`, `session_widgets.action_link_scheme`, and supported `widget_markdown_extensions`, then create/update concise `.md` widget files there with normal file tools. Widget creation, updates, and cleanup are mostly automated system behavior: use best judgment and ask first only when normal safety/tool policy absolutely requires approval or the widget would make a significant product/user-facing decision. Use checklists, supported widget_markdown_extensions from `agentd_context`, and action links such as `[Open checks](agentd:action/open-checks)` or `[Open checks](agentd:action/open-checks?key=o)` when a keyboard shortcut is desired. Treat `OBSERVATION: ui.action ...` as user intent; actions still go through normal tools and approvals.

Expand Down Expand Up @@ -1449,8 +1453,10 @@ mod tests {
#[test]
fn auto_review_prompt_guides_model_toward_routine_repo_work() {
assert!(AUTO_REVIEW_SYSTEM_PROMPT.contains("active git worktree"));
assert!(AUTO_REVIEW_SYSTEM_PROMPT
.contains("git makes those changes inspectable and reversible"));
assert!(
AUTO_REVIEW_SYSTEM_PROMPT
.contains("git makes those changes inspectable and reversible")
);
assert!(AUTO_REVIEW_SYSTEM_PROMPT.contains("cargo fmt --all"));
assert!(AUTO_REVIEW_SYSTEM_PROMPT.contains("cargo test"));
assert!(AUTO_REVIEW_SYSTEM_PROMPT.contains("git diff --name-only"));
Expand Down
59 changes: 46 additions & 13 deletions crates/adapter-zarvis/src/interactive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,18 @@
//! The TUI's `vt100`-backed terminal pane parses these bytes the same
//! way it parses any other PTY-backed adapter's output.

use crate::agent::{push_msg, system_prompt_for_env, ResolvedModel};
use crate::agent::{ResolvedModel, push_msg, system_prompt_for_env};
use crate::context;
use crate::persist::{self, Persist};
use crate::provider::{self, Content, Message, Role, StopReason, TextSink, ToolCall};
use crate::tools::{truncate_for_model, ToolCtx, ToolOutcome, ToolRegistry};
use crate::tools::{ToolCtx, ToolOutcome, ToolRegistry, truncate_for_model};
use agentd_protocol::adapter::{AdapterContext, AdapterInboxMsg, EventEmitter};
use agentd_protocol::{ApprovalMode, SessionEvent, SessionStartParams, SessionState, ToolRisk};
use anyhow::Result;
use serde_json::json;
use std::collections::{HashMap, VecDeque};
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use std::time::{Duration, SystemTime, UNIX_EPOCH};

const TOOL_OUTPUT_BUDGET: usize = 8_000;

Expand Down Expand Up @@ -364,6 +364,10 @@ struct PtySink<'a> {
/// Visible column inside the padded block (0 = just past left
/// padding). ASCII-counted; CJK chars may misalign.
col: usize,
/// When true, deltas render into the session PTY. Off for quiet
/// ambient Operator ticks where the model should update widgets or
/// say `noted` without adding visible minibuffer chatter.
emit_pty: bool,
/// When true, every delta also fires a `SessionEvent::Message` so
/// the daemon's transcript view sees the streaming text. Off for
/// replay paths where the message is already in the transcript.
Expand All @@ -386,6 +390,7 @@ impl<'a> PtySink<'a> {
width,
emitted: false,
col: 0,
emit_pty: true,
emit_messages: true,
status_started_at_ms,
in_reasoning: false,
Expand Down Expand Up @@ -492,7 +497,7 @@ impl<'a> PtySink<'a> {
for _ in 0..PAD_BOTTOM {
out.extend_from_slice(b"\r\n");
}
if !out.is_empty() {
if self.emit_pty && !out.is_empty() {
self.emit.emit(SessionEvent::pty(&out));
}
}
Expand Down Expand Up @@ -569,7 +574,7 @@ impl<'a> TextSink for PtySink<'a> {
out.extend_from_slice(s.as_bytes());
self.col += 1;
}
if !out.is_empty() {
if self.emit_pty && !out.is_empty() {
self.emit.emit(SessionEvent::pty(&out));
}
if self.emit_messages {
Expand Down Expand Up @@ -610,7 +615,7 @@ impl<'a> TextSink for PtySink<'a> {
out.extend_from_slice(s.as_bytes());
self.col += 1;
}
if !out.is_empty() {
if self.emit_pty && !out.is_empty() {
self.emit.emit(SessionEvent::pty(&out));
}
if self.emit_messages {
Expand Down Expand Up @@ -1979,6 +1984,7 @@ pub async fn run(
None
};
let mut obs_limiter = crate::observe::RateLimiter::new(5, std::time::Duration::from_secs(60));
let ambient_loop = is_orchestrator.then(operator_ambient_loop_interval);

'outer: loop {
// Wait for a user message — drain order: startup prompt
Expand Down Expand Up @@ -2013,6 +2019,7 @@ pub async fn run(
obs_rx.as_mut(),
&mut bg_completion_rx,
&tasks,
ambient_loop,
)
.await
{
Expand All @@ -2029,6 +2036,9 @@ pub async fn run(
term.note(&text);
text
}
ReadOutcome::AmbientTick => {
"OBSERVATION: ambient operator loop tick. Quietly inspect only if useful; update Operator widgets for helpful ambient status; reply exactly `noted` if nothing needs surfacing.".to_string()
}
ReadOutcome::BackgroundCompletion(bc) => {
// Emit the real ToolResult so the transcript +
// any MCP/CLI subscribers see the actual output
Expand Down Expand Up @@ -2224,9 +2234,7 @@ pub async fn run(
));
}
Ok(None) => {
term.note(
"(nothing to compact — not enough history)",
);
term.note("(nothing to compact — not enough history)");
}
Err(e) => {
term.note(&format!("(compact failed: {e})"));
Expand All @@ -2238,10 +2246,7 @@ pub async fn run(
// Routing::Adapter is only model/reset/compact today;
// any other id here is a registry/handler mismatch.
other => {
tracing::warn!(
?other,
"adapter-routed slash command has no handler"
);
tracing::warn!(?other, "adapter-routed slash command has no handler");
}
},
},
Expand Down Expand Up @@ -2324,6 +2329,7 @@ pub async fn run(
},
}
);
let ambient_turn = user_text.starts_with("OBSERVATION: ambient operator loop tick");
emit.emit(SessionEvent::Message {
role: agentd_protocol::MessageRole::User,
text: user_text,
Expand Down Expand Up @@ -2401,6 +2407,10 @@ pub async fn run(
}
let _pruned = context::prune_to_budget(&mut messages, budget);
let mut sink = PtySink::new(&emit, pty_width, turn_started_at_ms);
if ambient_turn {
sink.emit_pty = false;
sink.emit_messages = false;
}
// Wrap the provider call so user typing during the
// stream is fed to the editor and pressed-Enter lines
// join the pending-input queue instead of vanishing
Expand Down Expand Up @@ -2461,6 +2471,10 @@ pub async fn run(
)),
});
let mut sink2 = PtySink::new(&emit, pty_width, turn_started_at_ms);
if ambient_turn {
sink2.emit_pty = false;
sink2.emit_messages = false;
}
let drive2 = drive_with_input_silent(
&mut inbox,
&mut editor,
Expand Down Expand Up @@ -2876,6 +2890,8 @@ enum ReadOutcome {
/// while we were waiting for user input. The outer loop turns
/// this into a pseudo-user message so the agent can react.
Observation(crate::observe::Observation),
/// Ambient operator loop tick while the orchestrator is idle.
AmbientTick,
/// A backgrounded tool just finished. The outer loop emits the
/// real `ToolResult` event (so the transcript catches up) and
/// synthesizes an `OBSERVATION:` user message so the agent's
Expand All @@ -2885,6 +2901,15 @@ enum ReadOutcome {
Eof,
}

fn operator_ambient_loop_interval() -> Duration {
let secs = std::env::var("AGENTD_OPERATOR_AMBIENT_LOOP_SECS")
.ok()
.and_then(|raw| raw.parse::<u64>().ok())
.unwrap_or(300)
.clamp(60, 86_400);
Duration::from_secs(secs)
}

fn background_completion_observation_text(
call_id: &str,
tool_name: &str,
Expand Down Expand Up @@ -2915,6 +2940,7 @@ async fn read_one_line(
mut obs_rx: Option<&mut tokio::sync::mpsc::UnboundedReceiver<crate::observe::Observation>>,
bg_completion_rx: &mut tokio::sync::mpsc::UnboundedReceiver<crate::tasks::BackgroundCompletion>,
tasks: &std::sync::Arc<crate::tasks::Tasks>,
ambient_loop: Option<Duration>,
) -> ReadOutcome {
loop {
let inbox_recv = inbox.recv();
Expand All @@ -2925,6 +2951,12 @@ async fn read_one_line(
}
};
let bg_recv = bg_completion_rx.recv();
let ambient_tick = async {
match ambient_loop {
Some(interval) => tokio::time::sleep(interval).await,
None => std::future::pending().await,
}
};
let msg = tokio::select! {
biased;
obs = obs_recv => match obs {
Expand All @@ -2938,6 +2970,7 @@ async fn read_one_line(
Some(c) => return ReadOutcome::BackgroundCompletion(c),
None => continue, // channel closed; ignore
},
_ = ambient_tick => return ReadOutcome::AmbientTick,
m = inbox_recv => m,
};
match msg {
Expand Down
24 changes: 24 additions & 0 deletions specs/0020-operator-runs-ambiently.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# 0020-operator-runs-ambiently

Status: accepted
Date: 2026-06-06
Area: harness
Scope: Defines the Operator's autonomous ambient loop behavior.

## Decision

The daemon-owned Operator session may run an ambient loop while idle. The loop injects a synthetic observation into the Operator at a bounded interval, causing the same normal zarvis turn machinery to decide whether to inspect context, update widgets, notify the user, or do nothing.

Ambient loop turns are advisory and non-critical. The Operator should prefer silence or widget updates, and if nothing is worth surfacing it should answer exactly `noted`. Normal tool policy and approval rules still apply; the ambient loop does not grant extra authority or bypass risky-action approvals.

## Reason

The Operator is intended to be an ambient companion rather than only a command handler. A bounded idle loop lets a frontier model notice stale work, blockers, workflow issues, or useful status updates without requiring explicit user prompts, while preserving auditability because the work still happens inside the persisted Operator session.

## Consequences

Ambient autonomy must remain low-noise and interruptible by ordinary session controls. The loop should only run for the Operator/orchestrator session, should not run while the Operator is already handling user input or an event observation, and should be rate bounded. Clients and widgets must not depend on ambient-loop output for critical user journeys.

## Non-Goals

This does not create a hidden daemon brain, guaranteed notification delivery, or a separate scheduler for arbitrary autonomous actions. It does not make ambient actions exempt from approvals.
Loading