feat(web): toggle tool summaries mid-run
This commit is contained in:
@@ -32,7 +32,7 @@ read_when:
|
|||||||
- Levels: `on|full` or `off` (default).
|
- Levels: `on|full` or `off` (default).
|
||||||
- Directive-only message toggles session verbose and replies `Verbose logging enabled.` / `Verbose logging disabled.`; invalid levels return a hint without changing state.
|
- Directive-only message toggles session verbose and replies `Verbose logging enabled.` / `Verbose logging disabled.`; invalid levels return a hint without changing state.
|
||||||
- Inline directive affects only that message; session/global defaults apply otherwise.
|
- Inline directive affects only that message; session/global defaults apply otherwise.
|
||||||
- When verbose is on, agents that emit structured tool results (Pi, other JSON agents) send each tool result back as its own metadata-only message, prefixed with `[🛠️ <tool-name> <arg>]` when available (path/command); the tool output itself is not forwarded. These tool summaries are sent as soon as each tool finishes (separate bubbles), not as streaming deltas.
|
- When verbose is on, agents that emit structured tool results (Pi, other JSON agents) send each tool result back as its own metadata-only message, prefixed with `[🛠️ <tool-name> <arg>]` when available (path/command); the tool output itself is not forwarded. These tool summaries are sent as soon as each tool finishes (separate bubbles), not as streaming deltas. If you toggle `/verbose on|off` while a run is in-flight, subsequent tool bubbles honor the new setting.
|
||||||
|
|
||||||
## Heartbeats
|
## Heartbeats
|
||||||
- Heartbeat probe body is `HEARTBEAT`. Inline directives in a heartbeat message apply as usual (but avoid changing session defaults from heartbeats).
|
- Heartbeat probe body is `HEARTBEAT`. Inline directives in a heartbeat message apply as usual (but avoid changing session defaults from heartbeats).
|
||||||
|
|||||||
@@ -217,6 +217,7 @@ export async function runEmbeddedPiAgent(params: {
|
|||||||
timeoutMs: number;
|
timeoutMs: number;
|
||||||
runId: string;
|
runId: string;
|
||||||
abortSignal?: AbortSignal;
|
abortSignal?: AbortSignal;
|
||||||
|
shouldEmitToolResult?: () => boolean;
|
||||||
onPartialReply?: (payload: {
|
onPartialReply?: (payload: {
|
||||||
text?: string;
|
text?: string;
|
||||||
mediaUrls?: string[];
|
mediaUrls?: string[];
|
||||||
@@ -419,7 +420,11 @@ export async function runEmbeddedPiAgent(params: {
|
|||||||
isError,
|
isError,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (params.verboseLevel === "on" && params.onToolResult) {
|
const emitToolResult =
|
||||||
|
typeof params.shouldEmitToolResult === "function"
|
||||||
|
? params.shouldEmitToolResult()
|
||||||
|
: params.verboseLevel === "on";
|
||||||
|
if (emitToolResult && params.onToolResult) {
|
||||||
const agg = formatToolAggregate(
|
const agg = formatToolAggregate(
|
||||||
toolName,
|
toolName,
|
||||||
meta ? [meta] : undefined,
|
meta ? [meta] : undefined,
|
||||||
|
|||||||
@@ -2,13 +2,18 @@ import fs from "node:fs/promises";
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
vi.mock("../agents/pi-embedded.js", () => ({
|
vi.mock("../agents/pi-embedded.js", () => ({
|
||||||
runEmbeddedPiAgent: vi.fn(),
|
runEmbeddedPiAgent: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
|
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
|
||||||
|
import {
|
||||||
|
loadSessionStore,
|
||||||
|
resolveSessionKey,
|
||||||
|
saveSessionStore,
|
||||||
|
} from "../config/sessions.js";
|
||||||
import {
|
import {
|
||||||
extractThinkDirective,
|
extractThinkDirective,
|
||||||
extractVerboseDirective,
|
extractVerboseDirective,
|
||||||
@@ -28,6 +33,10 @@ async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("directive parsing", () => {
|
describe("directive parsing", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
@@ -91,8 +100,10 @@ describe("directive parsing", () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
const texts = (Array.isArray(res) ? res : [res])
|
||||||
expect(text).toBe("done");
|
.map((entry) => entry?.text)
|
||||||
|
.filter(Boolean);
|
||||||
|
expect(texts).toContain("done");
|
||||||
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce();
|
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -118,4 +129,118 @@ describe("directive parsing", () => {
|
|||||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("updates tool verbose during an in-flight run (toggle on)", async () => {
|
||||||
|
await withTempHome(async (home) => {
|
||||||
|
const storePath = path.join(home, "sessions.json");
|
||||||
|
const ctx = { Body: "please do the thing", From: "+1004", To: "+2000" };
|
||||||
|
const sessionKey = resolveSessionKey(
|
||||||
|
"per-sender",
|
||||||
|
{ From: ctx.From, To: ctx.To, Body: ctx.Body },
|
||||||
|
"main",
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.mocked(runEmbeddedPiAgent).mockImplementation(async (params) => {
|
||||||
|
const shouldEmit = params.shouldEmitToolResult;
|
||||||
|
expect(shouldEmit?.()).toBe(false);
|
||||||
|
const store = loadSessionStore(storePath);
|
||||||
|
const entry = store[sessionKey] ?? {
|
||||||
|
sessionId: "s",
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
store[sessionKey] = {
|
||||||
|
...entry,
|
||||||
|
verboseLevel: "on",
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
await saveSessionStore(storePath, store);
|
||||||
|
expect(shouldEmit?.()).toBe(true);
|
||||||
|
return {
|
||||||
|
payloads: [{ text: "done" }],
|
||||||
|
meta: {
|
||||||
|
durationMs: 5,
|
||||||
|
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await getReplyFromConfig(
|
||||||
|
ctx,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
inbound: {
|
||||||
|
allowFrom: ["*"],
|
||||||
|
workspace: path.join(home, "clawd"),
|
||||||
|
agent: { provider: "anthropic", model: "claude-opus-4-5" },
|
||||||
|
session: { store: storePath },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const texts = (Array.isArray(res) ? res : [res])
|
||||||
|
.map((entry) => entry?.text)
|
||||||
|
.filter(Boolean);
|
||||||
|
expect(texts).toContain("done");
|
||||||
|
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates tool verbose during an in-flight run (toggle off)", async () => {
|
||||||
|
await withTempHome(async (home) => {
|
||||||
|
const storePath = path.join(home, "sessions.json");
|
||||||
|
const ctx = {
|
||||||
|
Body: "please do the thing /verbose on",
|
||||||
|
From: "+1004",
|
||||||
|
To: "+2000",
|
||||||
|
};
|
||||||
|
const sessionKey = resolveSessionKey(
|
||||||
|
"per-sender",
|
||||||
|
{ From: ctx.From, To: ctx.To, Body: ctx.Body },
|
||||||
|
"main",
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.mocked(runEmbeddedPiAgent).mockImplementation(async (params) => {
|
||||||
|
const shouldEmit = params.shouldEmitToolResult;
|
||||||
|
expect(shouldEmit?.()).toBe(true);
|
||||||
|
const store = loadSessionStore(storePath);
|
||||||
|
const entry = store[sessionKey] ?? {
|
||||||
|
sessionId: "s",
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
store[sessionKey] = {
|
||||||
|
...entry,
|
||||||
|
verboseLevel: "off",
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
await saveSessionStore(storePath, store);
|
||||||
|
expect(shouldEmit?.()).toBe(false);
|
||||||
|
return {
|
||||||
|
payloads: [{ text: "done" }],
|
||||||
|
meta: {
|
||||||
|
durationMs: 5,
|
||||||
|
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await getReplyFromConfig(
|
||||||
|
ctx,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
inbound: {
|
||||||
|
allowFrom: ["*"],
|
||||||
|
workspace: path.join(home, "clawd"),
|
||||||
|
agent: { provider: "anthropic", model: "claude-opus-4-5" },
|
||||||
|
session: { store: storePath },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const texts = (Array.isArray(res) ? res : [res])
|
||||||
|
.map((entry) => entry?.text)
|
||||||
|
.filter(Boolean);
|
||||||
|
expect(texts).toContain("done");
|
||||||
|
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -330,6 +330,20 @@ export async function getReplyFromConfig(
|
|||||||
inlineVerbose ??
|
inlineVerbose ??
|
||||||
(sessionEntry?.verboseLevel as VerboseLevel | undefined) ??
|
(sessionEntry?.verboseLevel as VerboseLevel | undefined) ??
|
||||||
(agentCfg?.verboseDefault as VerboseLevel | undefined);
|
(agentCfg?.verboseDefault as VerboseLevel | undefined);
|
||||||
|
const shouldEmitToolResult = () => {
|
||||||
|
if (!sessionKey || !storePath) {
|
||||||
|
return resolvedVerboseLevel === "on";
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const store = loadSessionStore(storePath);
|
||||||
|
const entry = store[sessionKey];
|
||||||
|
const current = normalizeVerboseLevel(entry?.verboseLevel);
|
||||||
|
if (current) return current === "on";
|
||||||
|
} catch {
|
||||||
|
// ignore store read failures
|
||||||
|
}
|
||||||
|
return resolvedVerboseLevel === "on";
|
||||||
|
};
|
||||||
|
|
||||||
const combinedDirectiveOnly =
|
const combinedDirectiveOnly =
|
||||||
hasThinkDirective &&
|
hasThinkDirective &&
|
||||||
@@ -760,6 +774,7 @@ export async function getReplyFromConfig(
|
|||||||
mediaUrls: payload.mediaUrls,
|
mediaUrls: payload.mediaUrls,
|
||||||
})
|
})
|
||||||
: undefined,
|
: undefined,
|
||||||
|
shouldEmitToolResult,
|
||||||
onToolResult: opts?.onToolResult
|
onToolResult: opts?.onToolResult
|
||||||
? (payload) =>
|
? (payload) =>
|
||||||
opts.onToolResult?.({
|
opts.onToolResult?.({
|
||||||
|
|||||||
Reference in New Issue
Block a user