Verbose: include tool arg metadata in prefixes

This commit is contained in:
Peter Steinberger
2025-12-03 09:57:41 +00:00
parent 318166f8b0
commit 527bed2b53
8 changed files with 70 additions and 13 deletions

View File

@@ -4,7 +4,7 @@
### Highlights
- **Thinking directives & state:** `/t|/think|/thinking <level>` (aliases off|minimal|low|medium|high|max/highest). Inline applies to that message; directive-only message pins the level for the session; `/think:off` clears. Resolution: inline > session override > `inbound.reply.thinkingDefault` > off. Pi/Tau get `--thinking <level>` (except off); other agents append cue words (`think``think hard``think harder``ultrathink`). Heartbeat probe uses `HEARTBEAT /think:high`.
- **Verbose directives + session hints:** `/v|/verbose on|full|off` mirrors thinking: inline > session > config default. Directive-only replies with an acknowledgement; invalid levels return a hint. When enabled, tool results from JSON-emitting agents (Pi/Tau, etc.) are forwarded as metadata-only `[🛠️ <tool-name>]` messages (now streamed as they happen), and new sessions surface a `🧭 New session: <id>` hint.
- **Verbose directives + session hints:** `/v|/verbose on|full|off` mirrors thinking: inline > session > config default. Directive-only replies with an acknowledgement; invalid levels return a hint. When enabled, tool results from JSON-emitting agents (Pi/Tau, etc.) are forwarded as metadata-only `[🛠️ <tool-name> <arg>]` messages (now streamed as they happen), and new sessions surface a `🧭 New session: <id>` hint.
- **Directive confirmations:** Directive-only messages now reply with an acknowledgement (`Thinking level set to high.` / `Thinking disabled.`) and reject unknown levels with a helpful hint (state is unchanged).
- **Pi/Tau stability:** RPC replies buffered until the assistant turn finishes; parsers return consistent `texts[]`; web auto-replies keep a warm Tau RPC process to avoid cold starts.
- **Claude prompt flow:** One-time `sessionIntro` with per-message `/think:high` bodyPrefix; system prompt always sent on first turn even with `sendSystemOnce`.

View File

@@ -166,7 +166,7 @@ warelay supports running on the same phone number you message from—you chat wi
- Levels: `on|full` (same) or `off` (default). Use `/v on`, `/verbose:full`, `/v off`, etc.; colon optional.
- Directive-only message sets a session-level verbose flag (`Verbose logging enabled./disabled.`); invalid levels reply with a hint and dont change state.
- Inline directive applies only to that message; resolution: inline > session default > `inbound.reply.verboseDefault` (config) > off.
- When verbose is on **and the agent emits structured tool results (Pi/Tau and other JSON-emitting agents)**, only tool metadata is forwarded: each tool result becomes `[🛠️ <tool-name>]` (output/body is not inlined).
- When verbose is on **and the agent emits structured tool results (Pi/Tau and other JSON-emitting agents)**, only tool metadata is forwarded: each tool result becomes `[🛠️ <tool-name> <arg>]` when available (e.g., read path or bash command); output/body is not inlined.
- Starting a new session while verbose is on adds a first reply like `🧭 New session: <id>` so you can correlate runs.
### Logging (optional)

View File

@@ -28,7 +28,7 @@
- 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.
- Inline directive affects only that message; session/global defaults apply otherwise.
- When verbose is on, agents that emit structured tool results (Pi/Tau, other JSON agents) send each tool result back as its own metadata-only message, prefixed with `[🛠️ <tool-name>]` (the tool output itself is not forwarded).
- When verbose is on, agents that emit structured tool results (Pi/Tau, 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.
## Heartbeats
- Heartbeat probe body is `HEARTBEAT /think:high`, so it always asks for max thinking on the probe. Inline directive wins; session/global defaults are used only when no directive is present.

View File

@@ -69,11 +69,16 @@ describe("agent buildArgs + parseOutput helpers", () => {
it("piSpec carries tool names when present", () => {
const stdout =
'{"type":"message_end","message":{"role":"tool_result","name":"bash","content":[{"type":"text","text":"ls output"}]}}';
'{"type":"message_end","message":{"role":"tool_result","name":"bash","details":{"command":"ls -la"},"content":[{"type":"text","text":"ls output"}]}}';
const parsed = piSpec.parseOutput(stdout);
const tool = parsed.toolResults?.[0] as { text?: string; toolName?: string };
const tool = parsed.toolResults?.[0] as {
text?: string;
toolName?: string;
meta?: string;
};
expect(tool?.text).toBe("ls output");
expect(tool?.toolName).toBe("bash");
expect(tool?.meta).toBe("ls -la");
});
it("codexSpec parses agent_message and aggregates usage", () => {

View File

@@ -18,6 +18,8 @@ type PiAssistantMessage = {
toolName?: string;
tool_call_id?: string;
toolCallId?: string;
details?: Record<string, unknown>;
arguments?: Record<string, unknown>;
};
function inferToolName(msg: PiAssistantMessage): string | undefined {
@@ -39,6 +41,23 @@ function inferToolName(msg: PiAssistantMessage): string | undefined {
return undefined;
}
function deriveToolMeta(msg: PiAssistantMessage): string | undefined {
const details = msg.details ?? msg.arguments;
const pathVal = details && typeof details.path === "string" ? details.path : undefined;
const offset = details && typeof details.offset === "number" ? details.offset : undefined;
const limit = details && typeof details.limit === "number" ? details.limit : undefined;
const command = details && typeof details.command === "string" ? details.command : undefined;
if (pathVal) {
if (offset !== undefined && limit !== undefined) {
return `${pathVal}:${offset}-${offset + limit}`;
}
return pathVal;
}
if (command) return command;
return undefined;
}
function parsePiJson(raw: string): AgentParseResult {
const lines = raw.split(/\n+/).filter((l) => l.trim().startsWith("{"));
@@ -87,7 +106,11 @@ function parsePiJson(raw: string): AgentParseResult {
.join("\n")
.trim();
if (toolText) {
toolResults.push({ text: toolText, toolName: inferToolName(msg) });
toolResults.push({
text: toolText,
toolName: inferToolName(msg),
meta: deriveToolMeta(msg),
});
}
}
} catch {

View File

@@ -18,6 +18,7 @@ export type AgentMeta = {
export type AgentToolResult = {
text: string;
toolName?: string;
meta?: string;
};
export type AgentParseResult = {

View File

@@ -59,6 +59,8 @@ type ToolMessageLike = {
tool_call_id?: string;
toolCallId?: string;
role?: string;
details?: Record<string, unknown>;
arguments?: Record<string, unknown>;
};
function inferToolName(message?: ToolMessageLike): string | undefined {
@@ -80,6 +82,24 @@ function inferToolName(message?: ToolMessageLike): string | undefined {
return undefined;
}
function inferToolMeta(message?: ToolMessageLike): string | undefined {
if (!message) return undefined;
const details = message.details ?? message.arguments;
const pathVal = details && typeof details.path === "string" ? details.path : undefined;
const offset = details && typeof details.offset === "number" ? details.offset : undefined;
const limit = details && typeof details.limit === "number" ? details.limit : undefined;
const command = details && typeof details.command === "string" ? details.command : undefined;
if (pathVal) {
if (offset !== undefined && limit !== undefined) {
return `${pathVal}:${offset}-${offset + limit}`;
}
return pathVal;
}
if (command) return command;
return undefined;
}
function normalizeToolResults(
toolResults?: Array<string | AgentToolResult>,
): AgentToolResult[] {
@@ -89,13 +109,15 @@ function normalizeToolResults(
.map((tr) => ({
text: (tr.text ?? "").trim(),
toolName: tr.toolName?.trim() || undefined,
meta: tr.meta?.trim() || undefined,
}))
.filter((tr) => tr.text.length > 0);
}
function formatToolPrefix(toolName?: string) {
function formatToolPrefix(toolName?: string, meta?: string) {
const label = toolName?.trim() || "tool";
return `[🛠️ ${label}]`;
const extra = meta?.trim();
return extra ? `[🛠️ ${label} ${extra}]` : `[🛠️ ${label}]`;
}
export function summarizeClaudeMetadata(payload: unknown): string | undefined {
@@ -327,7 +349,12 @@ export async function runCommandReply(
try {
const ev = JSON.parse(line) as {
type?: string;
message?: { role?: string; content?: unknown[] };
message?: {
role?: string;
content?: unknown[];
details?: Record<string, unknown>;
arguments?: Record<string, unknown>;
};
};
if (
(ev.type === "message" || ev.type === "message_end") &&
@@ -335,7 +362,8 @@ export async function runCommandReply(
Array.isArray(ev.message.content)
) {
const toolName = inferToolName(ev.message);
const prefix = formatToolPrefix(toolName);
const meta = inferToolMeta(ev.message);
const prefix = formatToolPrefix(toolName, meta);
const { text: cleanedText, mediaUrls: mediaFound } =
splitMediaFromOutput(prefix);
void onPartialReply({
@@ -387,7 +415,7 @@ export async function runCommandReply(
if (includeToolResultsInline) {
for (const tr of parsedToolResults) {
const prefixed = formatToolPrefix(tr.toolName);
const prefixed = formatToolPrefix(tr.toolName, tr.meta);
const { text: cleanedText, mediaUrls: mediaFound } =
splitMediaFromOutput(prefixed);
replyItems.push({

View File

@@ -719,7 +719,7 @@ describe("config and templating", () => {
const rpcSpy = vi.spyOn(tauRpc, "runPiRpc").mockResolvedValue({
stdout:
'{"type":"message","message":{"role":"assistant","content":[{"type":"text","text":"summary"}]}}\n' +
'{"type":"message_end","message":{"role":"tool_result","name":"bash","content":[{"type":"text","text":"ls output"}]}}',
'{"type":"message_end","message":{"role":"tool_result","name":"bash","details":{"command":"ls"},"content":[{"type":"text","text":"ls output"}]}}',
stderr: "",
code: 0,
signal: null,
@@ -744,7 +744,7 @@ describe("config and templating", () => {
expect(rpcSpy).toHaveBeenCalled();
const payloads = Array.isArray(res) ? res : res ? [res] : [];
expect(payloads.length).toBeGreaterThanOrEqual(2);
expect(payloads[0]?.text).toBe("[🛠️ bash]");
expect(payloads[0]?.text).toBe("[🛠️ bash ls]");
expect(payloads[1]?.text).toContain("summary");
});