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
2 changes: 1 addition & 1 deletion doc/integration/02-acp-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ workdir = "."

When the AI calls `agent_context_gatherer(task="...")`, Octomind acts as the **ACP client**:
1. Spawns `octomind acp context_gatherer` as a subprocess.
2. Sends `initialize` (with `protocolVersion: "0.1.0"`) — it does **not** call `authenticate`.
2. Sends `initialize` (with `protocolVersion: 1`) — it does **not** call `authenticate`.
3. Sends `session/new` with an empty `mcpServers` list.
4. Sends `session/prompt` carrying the task as a single text block.
5. Accumulates every `agent_message_chunk` text into the result, and forwards intermediate `session/update` events (thinking, tool calls, tool results) up to the parent's notification sink so the user sees the sub-agent's progress live.
Expand Down
55 changes: 45 additions & 10 deletions src/mcp/agent/functions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -725,25 +725,21 @@ pub async fn run_acp_command(
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "0.1.0",
"clientInfo": {"name": "octomind-agent-tool", "version": "1.0"}
}
"params": acp_initialize_params()
}))
.as_bytes(),
)
.await?;
wait_for_response(&mut lines, 1).await?;

// 2. session/new
let cwd_str = workdir.to_string_lossy();
stdin
.write_all(
msg_line(json!({
"jsonrpc": "2.0",
"id": 2,
"method": "session/new",
"params": {"cwd": cwd_str, "mcpServers": []}
"params": acp_new_session_params(workdir)
}))
.as_bytes(),
)
Expand All @@ -764,10 +760,7 @@ pub async fn run_acp_command(
"jsonrpc": "2.0",
"id": 3,
"method": "session/prompt",
"params": {
"sessionId": session_id,
"prompt": [{"type": "text", "text": task}]
}
"params": acp_prompt_params(&session_id, task)
}))
.as_bytes(),
)
Expand Down Expand Up @@ -1050,6 +1043,27 @@ fn truncate_action(s: &str, max: usize) -> String {
}
}

/// Outgoing ACP request params, kept as functions so tests can validate them
/// against the `agent-client-protocol` schema types the octomind ACP server
/// deserializes with.
fn acp_initialize_params() -> Value {
json!({
"protocolVersion": 1,
"clientInfo": {"name": "octomind-agent-tool", "version": "1.0"}
})
}

fn acp_new_session_params(workdir: &std::path::Path) -> Value {
json!({"cwd": workdir.to_string_lossy(), "mcpServers": []})
}

fn acp_prompt_params(session_id: &str, task: &str) -> Value {
json!({
"sessionId": session_id,
"prompt": [{"type": "text", "text": task}]
})
}

/// Read lines until we find a JSON-RPC response with the given id, return it.
async fn wait_for_response(
lines: &mut tokio::io::Lines<BufReader<tokio::process::ChildStdout>>,
Expand Down Expand Up @@ -1086,6 +1100,27 @@ mod tests {
use super::*;
use std::time::{Duration, Instant};

/// The tap/agent client speaks raw JSON while the octomind ACP server
/// deserializes with the `agent-client-protocol` schema — pin every
/// outgoing params shape against those types so a crate upgrade that
/// changes the wire format fails here instead of at runtime (e.g. the
/// protocolVersion string → u16 break).
#[test]
fn outgoing_params_match_acp_schema() {
use agent_client_protocol::schema::v1::{
InitializeRequest, NewSessionRequest, PromptRequest,
};

serde_json::from_value::<InitializeRequest>(acp_initialize_params())
.expect("initialize params must match ACP v1 schema");
serde_json::from_value::<NewSessionRequest>(acp_new_session_params(std::path::Path::new(
"/tmp",
)))
.expect("session/new params must match ACP v1 schema");
serde_json::from_value::<PromptRequest>(acp_prompt_params("sess", "task"))
.expect("session/prompt params must match ACP v1 schema");
}

/// initialize (id=1) + session/new (id=2) + one streamed message chunk.
const HANDSHAKE: &str = r#"
echo '{"jsonrpc":"2.0","id":1,"result":{}}'
Expand Down
10 changes: 10 additions & 0 deletions src/session/chat/conversation_compression/ai.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,16 @@ async fn call_ai_for_decision(

let response = crate::session::chat_completion_with_validation(params).await?;

// Per-component spend for `/info` — recorded even when the decision ends up
// "don't compress" (the call happened) and even with ignore_cost (which only
// controls whether the spend counts toward the session total).
if let Some(usage) = &response.exchange.usage {
let stats = &mut session.session.info.compression_stats;
stats.input_tokens += usage.input_tokens;
stats.output_tokens += usage.output_tokens;
stats.cost += usage.cost.unwrap_or(0.0);
}

if !decision_config.ignore_cost {
if let Some(cost) = response.exchange.usage.as_ref().and_then(|u| u.cost) {
session.session.info.total_cost += cost;
Expand Down
34 changes: 34 additions & 0 deletions src/session/chat/response.rs
Original file line number Diff line number Diff line change
Expand Up @@ -637,6 +637,20 @@ pub async fn process_response<S: OutputSink>(
let mut round_truncated = false;
let mut round_dedup = false;
let mut round_drift = false;
// Observational verification (free pre-gate): fingerprint the working
// tree around the round — a changed tree IS the mutation signal,
// whatever tool made the change. Only measured when the pre-gate
// consumes it (one git spawn per round).
let track_verification = params.config.supervisor.enabled
&& params.config.supervisor.gate.enabled
&& params.config.supervisor.gate.require_check_after_mutation;
let fp_before = if track_verification {
crate::supervisor::workdir::fingerprint()
} else {
None
};
let mut round_verifier = false;
let mut round_mutation = false;

for call in &current_tool_calls {
let tr = tool_results.iter().find(|r| r.tool_id == call.tool_id);
Expand Down Expand Up @@ -717,6 +731,26 @@ pub async fn process_response<S: OutputSink>(
round_truncated |= is_truncated;
round_dedup |= is_dedup;
round_drift |= is_drift;
if !is_error {
round_mutation |= is_mutation;
round_verifier |= crate::supervisor::detect::is_verifier_shaped(
&call.tool_name,
&call.parameters,
);
}
}

// Fold the round into the observational verification state: a round
// verifies only when a verifier-shaped call succeeded on a tree the
// round itself did not change.
if track_verification {
let fp_after = crate::supervisor::workdir::fingerprint();
params.chat_session.detectors.note_round_verification(
fp_before,
fp_after,
round_verifier,
round_mutation,
);
}

// One verdict per ROUND: the whole parallel batch is a single model decision,
Expand Down
11 changes: 10 additions & 1 deletion src/session/chat/session/api_executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -185,9 +185,16 @@ pub async fn execute_api_call_and_process_response<S: OutputSink>(
// Prefer the live plan checklist (refreshed every turn from plan storage)
// over the anchor's stale next_steps snapshot for the recency-slot block.
let plan_checklist = crate::mcp::core::plan::render_plan_checklist();
// Explicit prohibitions from the genuine request, recited verbatim —
// these are the instructions models abandon first as attention decays.
let constraints =
crate::session::latest_real_user_task_content(&chat_session.session.messages)
.map(crate::supervisor::recite::extract_constraints)
.unwrap_or_default();
if let Some(note) = crate::supervisor::recite::recite_note(
&chat_session.session.info.anchor,
plan_checklist.as_deref(),
&constraints,
) {
chat_session.add_system_managed_user_message(&note)?;
crate::log_debug!("Supervisor goal recitation injected");
Expand Down Expand Up @@ -334,7 +341,9 @@ pub async fn execute_api_call_and_process_response<S: OutputSink>(
.any(|m| m.content.contains(PREGATE_MARKER))
};
if config.supervisor.gate.require_check_after_mutation
&& chat_session.detectors.needs_verification()
&& chat_session
.detectors
.needs_verification(crate::supervisor::workdir::fingerprint())
&& !already_nudged
{
chat_session.add_system_managed_user_message(PREGATE_NOTE)?;
Expand Down
22 changes: 22 additions & 0 deletions src/session/chat/session/commands/display.rs
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,8 @@ pub fn display_info(output: &CommandOutput) {
"messages removed",
"tokens saved",
"avg ratio",
"tokens",
"cost",
]);
if stats.conversation_compressions > 0 {
block_row(
Expand Down Expand Up @@ -501,6 +503,26 @@ pub fn display_info(output: &CommandOutput) {
if avg_ratio > 0.0 {
block_row("avg ratio", &format!("{:.1}%", avg_ratio), kw);
}
// Compression model's own spend — separate model, so break it out.
if stats.input_tokens > 0 || stats.output_tokens > 0 {
block_row(
"tokens",
&format!(
"{} in {} {} out",
format_number(stats.input_tokens).bright_blue(),
dot,
format_number(stats.output_tokens).bright_green(),
),
kw,
);
}
if stats.cost > 0.0 {
block_row(
"cost",
&format!("${:.5}", stats.cost).bright_yellow().to_string(),
kw,
);
}
}

// ── cache ──────────────────────────────────────────────────────
Expand Down
4 changes: 3 additions & 1 deletion src/session/chat/session/commands/info.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,9 @@ pub fn handle_info(session: &ChatSession, config: &Config) -> Result<CommandResu
0.0
};

let compression_stats = if info.compression_stats.total_compressions() > 0 {
let compression_stats = if info.compression_stats.total_compressions() > 0
|| info.compression_stats.input_tokens > 0
{
Some(info.compression_stats.clone())
} else {
None
Expand Down
2 changes: 1 addition & 1 deletion src/session/chat/session/messages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,7 @@ impl ChatSession {
self.last_gate_gaps.clear();
// Reset the detector rolling windows (loop / no-progress / truncation /
// dedup / drift streaks) so a new task doesn't inherit the previous task's
// streaks. Trajectory state (unverified_mutation) is intentionally kept.
// streaks. Verification state (verified fingerprint) is intentionally kept.
self.detectors.reset_streak();

// Check if we should cache this user message (after push, so the message exists
Expand Down
8 changes: 8 additions & 0 deletions src/session/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,14 @@ pub struct CompressionStats {
pub conversation_compressions: usize,
pub total_messages_removed: usize,
pub total_tokens_saved: u64,
// The compression decision model's own spend — a separate model from the
// agent, so `/info` can break it out while total_cost stays the overall sum.
#[serde(default)]
pub input_tokens: u64,
#[serde(default)]
pub output_tokens: u64,
#[serde(default)]
pub cost: f64,
}

impl CompressionStats {
Expand Down
Loading
Loading