From d756f5bcc125047b4a673efe6c1cfcaf089cd53e Mon Sep 17 00:00:00 2001 From: Yidhar <80593214+Yidhar@users.noreply.github.com> Date: Mon, 29 Jun 2026 14:43:30 +0800 Subject: [PATCH 1/2] fix(kohaku): quiet kt stderr plugin noise via --log-level error kt-biome plugins (the PEV verifier and the OpenTelemetry exporter) log benign WARNING-level "plugin disabled / no-op" lines on every agent build: PEV verifier has no acceptance_criteria; plugin disabled opentelemetry packages not installed; plugin is no-op Switchyard captures kt's stderr and surfaces it as a failed turn's error_message + an always-saved diagnostics artifact, so this benign noise dominated the view (and repeated every turn). Pass `--log-level error` to `kt run` so only real errors reach stderr. Actual turn outcomes arrive on the stdout JSONL (`turn_end`), independent of the stderr log level, so no failure signal is lost. fake_kt now asserts the adapter always passes --log-level. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/switchyard-provider-kohaku/src/turn.rs | 10 +++++++++- tests/bin/fake_kt.rs | 7 ++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/crates/switchyard-provider-kohaku/src/turn.rs b/crates/switchyard-provider-kohaku/src/turn.rs index 717325e..06d94c7 100644 --- a/crates/switchyard-provider-kohaku/src/turn.rs +++ b/crates/switchyard-provider-kohaku/src/turn.rs @@ -61,7 +61,7 @@ pub async fn run_kohaku_turn( return Err(err); } - // argv: kt run --headless --json + // argv: kt run --headless --json --log-level error // [--llm ] --sandbox --cwd --no-subagents // -p // The creature ref must lead so it binds to the required `agent_path` @@ -70,6 +70,14 @@ pub async fn run_kohaku_turn( args.extend_from_slice(configured_args); args.push("--headless".to_string()); args.push("--json".to_string()); + // Quiet kt's stderr to errors only. Plugins (e.g. kt-biome's PEV verifier + // and OpenTelemetry exporter) log benign WARNING-level "plugin disabled / + // no-op" noise on every build; Switchyard captures kt stderr and surfaces it + // as a failed turn's reason + a diagnostics artifact, so that noise would + // otherwise dominate the view. Real turn outcomes arrive on the stdout JSONL + // (`turn_end`), independent of this log level, so nothing actionable is lost. + args.push("--log-level".to_string()); + args.push("ERROR".to_string()); args.extend(kohaku_runtime_args(model, thinking_level)); args.extend(kohaku_policy_args(policy)); // Leaf execution: a routed/peer run must not spawn sub-agents. diff --git a/tests/bin/fake_kt.rs b/tests/bin/fake_kt.rs index b716ec6..c119bf7 100644 --- a/tests/bin/fake_kt.rs +++ b/tests/bin/fake_kt.rs @@ -21,11 +21,16 @@ fn main() { let args: Vec = std::env::args().collect(); eprintln!("[fake_kt] argv = {args:?}"); - // The adapter must always request the headless JSONL surface. + // The adapter must always request the headless JSONL surface, and quiet + // kt's stderr to errors only so benign plugin warnings don't pollute the UI. if !args.iter().any(|a| a == "--headless") || !args.iter().any(|a| a == "--json") { eprintln!("[fake_kt] missing --headless/--json"); std::process::exit(3); } + if !args.iter().any(|a| a == "--log-level") { + eprintln!("[fake_kt] missing --log-level (stderr noise must be quieted)"); + std::process::exit(4); + } let prompt = args .iter() From 411491452a0edb1d7edaff7aabf646a4e8305b8f Mon Sep 17 00:00:00 2001 From: Yidhar <80593214+Yidhar@users.noreply.github.com> Date: Mon, 29 Jun 2026 15:15:25 +0800 Subject: [PATCH 2/2] feat(kohaku/gui): richer live-execution detail for kt tool activity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Map kt tool/subagent activity to `tool_call` items carrying the real tool name (derived from the kt job_id "_"), the call arguments (path/content — shown via the card's Show-Input toggle, truncated when long), and the result — instead of the generic, content-less `command_execution` previously emitted (which rendered every tool as "Execute Command" with no content). The kt job_id is stable across start/done, so it's used as the item id and a tool's start (running, with args) merges with its later done/error (with the result) into one transitioning card. GUI live-execution card (RenderHelpers): - Headline falls back to the latest tool of ANY kind (not just shell commands), so non-command tool turns no longer read "暂无工具事件". - Adds a "已调用 N 个工具" summary count for non-command, non-edit tool calls. - Shows the LATEST 5 detail cards (was the first 5, which never scrolled to the newest activity) and notes how many earlier ones are folded. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/components/ui/RenderHelpers.tsx | 37 +++-- crates/switchyard-provider-kohaku/src/turn.rs | 133 +++++++++++++----- tests/bin/fake_kt.rs | 7 +- tests/tests/kohaku_integration.rs | 29 ++-- 4 files changed, 144 insertions(+), 62 deletions(-) diff --git a/crates/switchyard-gui/frontend/src/components/ui/RenderHelpers.tsx b/crates/switchyard-gui/frontend/src/components/ui/RenderHelpers.tsx index 145beca..7e15e3d 100644 --- a/crates/switchyard-gui/frontend/src/components/ui/RenderHelpers.tsx +++ b/crates/switchyard-gui/frontend/src/components/ui/RenderHelpers.tsx @@ -2585,7 +2585,7 @@ export function renderTurnActivitySummary( const latestTerminalLine = terminalTail[terminalTail.length - 1] ?? null; const runningEdit = editSummaries.find((item) => item.status === 'running' || item.status === 'pending'); const latestEdit = runningEdit ?? (turnIsActive ? editSummaries[editSummaries.length - 1] : undefined); - const runningCommand = displayToolCalls.find((tool) => isCommandTool(tool) && (tool.status === 'running' || tool.status === 'pending')); + const runningTool = displayToolCalls.find((tool) => tool.status === 'running' || tool.status === 'pending'); const hasActivity = Boolean(commandLine || displayToolCalls.length > 0 || combinedTerminal.length > 0); const runtimePhase = options.runtimePhase; const phaseStatusText = runtimePhaseStatusText(runtimePhase, turnIsActive); @@ -2594,31 +2594,38 @@ export function renderTurnActivitySummary( runtimePhase === 'output_completed' || runtimePhase === 'finalizing' || runtimePhase === 'failed'; + // Non-command, non-edit tool calls (read / search / …) — counted separately + // from shell commands and file edits so a provider that mostly calls tools + // (e.g. kohaku) still shows its activity in the summary. + const toolCallCount = displayToolCalls.filter( + (tool) => !isCommandTool(tool) && !isEditTool(tool), + ).length; const summaryParts = [ createdCount > 0 ? `已创建 ${createdCount} 个文件` : null, editedCompletedCount > 0 ? `已编辑 ${editedCompletedCount} 个文件` : null, editingCount > 0 ? `正在编辑 ${editingCount} 个文件` : null, failedEditCount > 0 ? `${failedEditCount} 个文件编辑失败` : null, commandCount > 0 ? `已运行 ${commandCount} 条命令` : null, + toolCallCount > 0 ? `已调用 ${toolCallCount} 个工具` : null, showPhaseStatus ? phaseStatusText : null, ].filter(Boolean); const phaseIsActive = runtimePhase === 'running' || runtimePhase === 'output_completed' || runtimePhase === 'finalizing'; const showSpinner = phaseIsActive || turnIsActive || hasRunningTool || hasLiveTerminal; const detailToolCalls = displayToolCalls.filter((tool) => !actionableTools.includes(tool)); - // Prefer a currently-running command; otherwise fall back to the most recent - // command that already ran. Without this, a provider whose tools have all - // completed (e.g. kohaku between tool batches, with no launcher commandLine) - // would fall through to the "暂无工具事件" status even though `commandCount` - // shows commands ran — a confusing contradiction. - const latestCommandTool = runningCommand - ?? [...displayToolCalls].reverse().find((tool) => isCommandTool(tool)); - const commandActivity = latestCommandTool - ? activeToolInputLabel(latestCommandTool) + // Headline: show the latest tool activity — a currently-running tool (command + // or otherwise), else the most recent tool that ran. The non-command fallback + // matters for providers whose tools are mostly non-command and complete fast + // (e.g. kohaku running read/write/edit): without it the card would fall + // through to the "暂无工具事件" status even though tools clearly ran. + const latestActivityTool = runningTool + ?? (displayToolCalls.length > 0 ? displayToolCalls[displayToolCalls.length - 1] : undefined); + const commandActivity = latestActivityTool + ? activeToolInputLabel(latestActivityTool) : commandLine ? truncateActivityText(`${commandLine}${commandArgsSuffix(state.commandArgs)}`, 180) : null; - const commandVerb = runningCommand || hasRunningTool || hasLiveTerminal || (!latestCommandTool && runtimePhase === 'running') + const commandVerb = runningTool || hasLiveTerminal || (!latestActivityTool && runtimePhase === 'running') ? '正在运行' : '已运行'; const editActivity = latestEdit @@ -2763,12 +2770,12 @@ export function renderTurnActivitySummary( {commandLine}{commandArgsSuffix(state.commandArgs)} )} - {detailToolCalls.slice(0, 5).map((tool, idx) => ( - - ))} {detailToolCalls.length > 5 && ( -
还有 {detailToolCalls.length - 5} 项执行详情会在回合结束后折叠显示。
+
更早的 {detailToolCalls.length - 5} 项执行详情已折叠,回合结束后可查看完整列表。
)} + {detailToolCalls.slice(-5).map((tool) => ( + + ))} {combinedTerminal.length > 0 && (
                 {combinedTerminal.slice(-80).join('\n')}
diff --git a/crates/switchyard-provider-kohaku/src/turn.rs b/crates/switchyard-provider-kohaku/src/turn.rs
index 06d94c7..94c2d11 100644
--- a/crates/switchyard-provider-kohaku/src/turn.rs
+++ b/crates/switchyard-provider-kohaku/src/turn.rs
@@ -192,11 +192,11 @@ pub async fn run_kohaku_turn(
                     value,
                 } => {
                     // Translate genuine tool/subagent calls into normalized
-                    // command_execution items so they're counted and surfaced in
-                    // the live-execution card (matching codex/claude). Pure KT
-                    // runtime telemetry (processing_*, token_usage, session_info,
-                    // compact_*, …) is backend noise and is dropped so the chat
-                    // shows only the model's message.
+                    // tool_call items (real name + args + result) so the
+                    // live-execution card shows what the model actually did.
+                    // Pure KT runtime telemetry (processing_*, token_usage,
+                    // session_info, compact_*, …) is backend noise and is
+                    // dropped so the chat shows only the model's message.
                     if let Some(item) = normalize_tool_activity(&activity_type, &value) {
                         event_tx_clone
                             .send(ProviderEvent::new(
@@ -332,15 +332,16 @@ fn partial_begin_suffix_len(tail: &str) -> usize {
 
 /// Map a KohakuTerrarium `activity` event onto Switchyard's normalized item
 /// vocabulary, or `None` if it is backend runtime *telemetry* that should not
-/// reach the chat. Genuine tool/subagent calls become `command_execution`
-/// items so the live-execution card counts them ("已运行 N 条命令") and shows
-/// the running one in its headline ("正在运行 ") — the same surfaces
-/// codex/claude drive via their command items. A `*_start` becomes a running
-/// item that the frontend merges (by command) with its later `*_done`/`*_error`
-/// into one transitioning card. Pure telemetry (processing_*, token_usage,
-/// *_token_update, session_info, compact_*, tool_promoted, job_cancelled,
-/// interrupt, …) and unknown types are dropped so a vocabulary drift in the KT
-/// fork never reintroduces flooding.
+/// reach the chat. Genuine tool/subagent calls become `tool_call` items so the
+/// live-execution card shows the real tool name (not a generic "Execute
+/// Command"), the call arguments (file path / content, collapsible via the
+/// card's Show-Input toggle), and the result. The KT `job_id`
+/// (`_`, stable across start/done) is used as the item id so a
+/// `*_start` (running, with args) and its later `*_done`/`*_error` (with the
+/// result) merge into ONE transitioning card. Pure telemetry (processing_*,
+/// token_usage, *_token_update, session_info, compact_*, tool_promoted,
+/// job_cancelled, interrupt, …) and unknown types are dropped so a vocabulary
+/// drift in the KT fork never reintroduces flooding.
 fn normalize_tool_activity(
     activity_type: &str,
     value: &serde_json::Value,
@@ -351,23 +352,53 @@ fn normalize_tool_activity(
         "tool_error" | "subagent_tool_error" | "subagent_error" => "failed",
         _ => return None,
     };
-    let name = value
-        .get("detail")
+    let metadata = value.get("metadata");
+    let job_id = metadata
+        .and_then(|m| m.get("job_id"))
         .and_then(|v| v.as_str())
+        .filter(|s| !s.trim().is_empty());
+    // KT job_id is "_"; strip the suffix for a clean title.
+    let name = job_id
+        .map(|id| id.rsplit_once('_').map(|(head, _)| head).unwrap_or(id))
         .filter(|s| !s.trim().is_empty())
         .or_else(|| {
-            value
-                .get("metadata")
+            metadata
                 .and_then(|m| m.get("name"))
                 .and_then(|v| v.as_str())
         })
         .filter(|s| !s.trim().is_empty())
         .unwrap_or("tool");
-    Some(serde_json::json!({
-        "item_type": "command_execution",
-        "command": name,
-        "status": status,
-    }))
+
+    let mut item = serde_json::Map::new();
+    item.insert("item_type".into(), serde_json::json!("tool_call"));
+    item.insert("name".into(), serde_json::json!(name));
+    item.insert("status".into(), serde_json::json!(status));
+    // Stable id so start/done/error for the same call merge into one card.
+    if let Some(id) = job_id {
+        item.insert("id".into(), serde_json::json!(id));
+    }
+    // Call arguments (path / content / …) — shown via the card's Show-Input
+    // toggle, truncated there when long.
+    if let Some(args) = metadata.and_then(|m| m.get("args"))
+        && !args.is_null()
+    {
+        item.insert("arguments".into(), args.clone());
+    }
+    // Tool result (done/error) — shown as the card output (diff-aware,
+    // collapsed when large).
+    if let Some(result) = metadata.and_then(|m| m.get("result"))
+        && !result.is_null()
+        && result.as_str() != Some("")
+    {
+        item.insert("output".into(), result.clone());
+    }
+    if status == "failed"
+        && let Some(error) = metadata.and_then(|m| m.get("error"))
+        && !error.is_null()
+    {
+        item.insert("error".into(), error.clone());
+    }
+    Some(serde_json::Value::Object(item))
 }
 
 /// Map Switchyard's sandbox mode + workspace onto `kt` headless flags.
@@ -462,41 +493,69 @@ mod tests {
         assert!(normalize_tool_activity("session_info", &serde_json::json!({})).is_none());
         assert!(normalize_tool_activity("brand_new_event", &serde_json::json!({})).is_none());
 
-        // A tool start becomes a running command_execution (the frontend merges
-        // it with the later done/error by command into one transitioning card),
-        // so the live card counts it and shows it in the headline.
-        let start =
-            normalize_tool_activity("tool_start", &serde_json::json!({"detail": "read"})).unwrap();
+        // A tool start becomes a running tool_call carrying the real name (from
+        // the KT job_id), the call args (shown via Show-Input), and the stable
+        // id (so done/error merge into one card).
+        let start = normalize_tool_activity(
+            "tool_start",
+            &serde_json::json!({
+                "detail": "[read[ab12]] path=x",
+                "metadata": {"job_id": "read_ab12cd", "args": {"path": "x"}}
+            }),
+        )
+        .unwrap();
         assert_eq!(
             start.get("item_type").and_then(|v| v.as_str()),
-            Some("command_execution")
+            Some("tool_call")
+        );
+        assert_eq!(start.get("name").and_then(|v| v.as_str()), Some("read"));
+        assert_eq!(
+            start.get("id").and_then(|v| v.as_str()),
+            Some("read_ab12cd")
         );
-        assert_eq!(start.get("command").and_then(|v| v.as_str()), Some("read"));
         assert_eq!(
             start.get("status").and_then(|v| v.as_str()),
             Some("running")
         );
+        assert_eq!(
+            start
+                .get("arguments")
+                .and_then(|a| a.get("path"))
+                .and_then(|v| v.as_str()),
+            Some("x")
+        );
 
-        let done =
-            normalize_tool_activity("tool_done", &serde_json::json!({"detail": "read"})).unwrap();
+        // The matching done carries the same id (→ merge) and the result.
+        let done = normalize_tool_activity(
+            "tool_done",
+            &serde_json::json!({"metadata": {"job_id": "read_ab12cd", "result": "file body"}}),
+        )
+        .unwrap();
+        assert_eq!(done.get("id").and_then(|v| v.as_str()), Some("read_ab12cd"));
         assert_eq!(
             done.get("status").and_then(|v| v.as_str()),
             Some("completed")
         );
+        assert_eq!(
+            done.get("output").and_then(|v| v.as_str()),
+            Some("file body")
+        );
 
+        // tool_error surfaces the error and a failed status.
         let errored = normalize_tool_activity(
             "tool_error",
-            &serde_json::json!({"metadata": {"name": "write"}}),
+            &serde_json::json!({"metadata": {"job_id": "write_99", "error": "denied"}}),
         )
         .unwrap();
-        assert_eq!(
-            errored.get("command").and_then(|v| v.as_str()),
-            Some("write")
-        );
+        assert_eq!(errored.get("name").and_then(|v| v.as_str()), Some("write"));
         assert_eq!(
             errored.get("status").and_then(|v| v.as_str()),
             Some("failed")
         );
+        assert_eq!(
+            errored.get("error").and_then(|v| v.as_str()),
+            Some("denied")
+        );
     }
 
     #[test]
diff --git a/tests/bin/fake_kt.rs b/tests/bin/fake_kt.rs
index c119bf7..3045e34 100644
--- a/tests/bin/fake_kt.rs
+++ b/tests/bin/fake_kt.rs
@@ -78,13 +78,16 @@ fn main() {
         );
     }
 
+    // Real kt tool activity: start carries job_id ("_") + args;
+    // done carries the same job_id + the result. The adapter derives the tool
+    // name from job_id and merges start/done into one card by that id.
     emit(
         &mut out,
-        serde_json::json!({"type":"activity","activity_type":"tool_start","detail":"read","metadata":{"name":"read","args":{"path":"x"}}}),
+        serde_json::json!({"type":"activity","activity_type":"tool_start","detail":"[read[ab12]] path=x","metadata":{"job_id":"read_ab12cd","args":{"path":"x"},"background":false}}),
     );
     emit(
         &mut out,
-        serde_json::json!({"type":"activity","activity_type":"tool_done","detail":"read","metadata":{}}),
+        serde_json::json!({"type":"activity","activity_type":"tool_done","detail":"[read[ab12]]","metadata":{"job_id":"read_ab12cd","result":"file body","tools_used":[]}}),
     );
     emit(
         &mut out,
diff --git a/tests/tests/kohaku_integration.rs b/tests/tests/kohaku_integration.rs
index 5d1eb52..1397837 100644
--- a/tests/tests/kohaku_integration.rs
+++ b/tests/tests/kohaku_integration.rs
@@ -125,7 +125,7 @@ async fn kohaku_keeps_chat_clean_of_protocol_and_sentinel() {
     let mut display = String::new();
     let mut terminal_outputs = 0;
     let mut execution_telemetry = 0;
-    let mut command_executions: Vec = vec![];
+    let mut tool_calls: Vec<(String, Option)> = vec![];
     while let Some(e) = rx.recv().await {
         match e.payload.get("item_type").and_then(|v| v.as_str()) {
             Some("agent_message") => {
@@ -135,9 +135,14 @@ async fn kohaku_keeps_chat_clean_of_protocol_and_sentinel() {
             }
             Some("terminal_output") => terminal_outputs += 1,
             Some("execution_telemetry") => execution_telemetry += 1,
-            Some("command_execution") => {
-                if let Some(c) = e.payload.get("command").and_then(|v| v.as_str()) {
-                    command_executions.push(c.to_string());
+            Some("tool_call") => {
+                if let Some(name) = e.payload.get("name").and_then(|v| v.as_str()) {
+                    let id = e
+                        .payload
+                        .get("id")
+                        .and_then(|v| v.as_str())
+                        .map(str::to_string);
+                    tool_calls.push((name.to_string(), id));
                 }
             }
             _ => {}
@@ -153,11 +158,19 @@ async fn kohaku_keeps_chat_clean_of_protocol_and_sentinel() {
         "the kt.exe driver must not be surfaced as execution_telemetry (it would \
          headline the live card as '正在运行 kt.exe' and inflate the command count)"
     );
-    // The fake runs a `read` tool — it must reach the live card as a counted
-    // command_execution (start->done), not vanish.
+    // The fake runs a `read` tool (start+done) — it must surface as tool_call
+    // items named "read" (not a generic "Execute Command"), carrying the stable
+    // job_id so the two merge into one card.
     assert!(
-        command_executions.iter().any(|c| c == "read"),
-        "kt tool activity should surface as command_execution items, got: {command_executions:?}"
+        tool_calls.iter().any(|(name, _)| name == "read"),
+        "kt tool activity should surface as named tool_call items, got: {tool_calls:?}"
+    );
+    assert!(
+        tool_calls
+            .iter()
+            .filter(|(name, _)| name == "read")
+            .all(|(_, id)| id.as_deref() == Some("read_ab12cd")),
+        "tool_call start/done must share the kt job_id so they merge, got: {tool_calls:?}"
     );
     assert!(
         !display.contains("SWITCHYARD_JSON"),