From f2a71af8d2c1281a0e7411ab66fef4af3b07d4d3 Mon Sep 17 00:00:00 2001 From: elkaix Date: Tue, 11 Aug 2026 18:48:57 -0400 Subject: [PATCH 1/6] feat: show model-written tool intent in the working indicator Inject a required first field "i" (concise intent) into tool schemas sent to providers, gated by the tool_intent experimental flag (default on). The runtime strips the field before validation, hooks, execution, and persistence, and carries it on the tool.call.started event. The TUI shows the intent live in the spinner label, streamed from partial tool-call arguments, and falls back to the rotating verbs without it. --- .changeset/tool-intent-indicator.md | 5 + .../src/tui/constant/rendering.ts | 22 +++- .../tui/controllers/session-event-handler.ts | 12 ++ .../src/tui/controllers/streaming-ui.ts | 4 +- .../test/tui/tool-intent-label.test.ts | 83 +++++++++++++ docs/configuration/config-files.md | 3 +- docs/configuration/env-vars.md | 1 + packages/agent-core/src/agent/turn/index.ts | 3 + packages/agent-core/src/flags/registry.ts | 8 ++ packages/agent-core/src/loop/events.ts | 1 + packages/agent-core/src/loop/run-turn.ts | 3 + packages/agent-core/src/loop/tool-call.ts | 37 +++++- packages/agent-core/src/loop/tool-intent.ts | 64 ++++++++++ packages/agent-core/src/loop/turn-step.ts | 6 +- .../test/loop/tool-call.e2e.test.ts | 79 ++++++++++++- .../agent-core/test/loop/tool-intent.test.ts | 110 ++++++++++++++++++ .../protocol/src/__tests__/events.test.ts | 1 + packages/protocol/src/events.ts | 2 + 18 files changed, 434 insertions(+), 10 deletions(-) create mode 100644 .changeset/tool-intent-indicator.md create mode 100644 apps/pythinker-code/test/tui/tool-intent-label.test.ts create mode 100644 packages/agent-core/src/loop/tool-intent.ts create mode 100644 packages/agent-core/test/loop/tool-intent.test.ts diff --git a/.changeset/tool-intent-indicator.md b/.changeset/tool-intent-indicator.md new file mode 100644 index 00000000..c93ff013 --- /dev/null +++ b/.changeset/tool-intent-indicator.md @@ -0,0 +1,5 @@ +--- +"@pythoughts/pythinker-code": minor +--- + +Show what the agent is doing in the working indicator: each tool call now carries a short model-written intent, streamed live into the spinner label (for example "check failing test…") instead of a random verb; disable with `PYTHINKER_CODE_EXPERIMENTAL_TOOL_INTENT=0`. diff --git a/apps/pythinker-code/src/tui/constant/rendering.ts b/apps/pythinker-code/src/tui/constant/rendering.ts index a4c304d9..0d823fba 100644 --- a/apps/pythinker-code/src/tui/constant/rendering.ts +++ b/apps/pythinker-code/src/tui/constant/rendering.ts @@ -132,6 +132,26 @@ export const THINKING_SPINNER_LABELS = [ export const THINKING_SPINNER_LABEL_INTERVAL_MS = 12_000; +const LIVE_INTENT_MAX_LENGTH = 120; +// oxlint-disable-next-line no-control-regex -- wire text must not retain terminal escape sequences. +const ANSI_ESCAPE = /\u001B(?:\[[0-?]*[ -/]*[@-~]|\][^\u0007]*(?:\u0007|$))/gu; +const CONTROL_CHARACTER = /\p{Cc}/gu; +let liveIntent: string | undefined; + +export function setLiveIntent(text: string | undefined): void { + if (text === undefined) { + liveIntent = undefined; + return; + } + const normalized = text + .replaceAll(ANSI_ESCAPE, '') + .replaceAll(CONTROL_CHARACTER, ' ') + .replaceAll(/\s+/gu, ' ') + .trim(); + liveIntent = + Array.from(normalized).slice(0, LIVE_INTENT_MAX_LENGTH).join('').trimEnd() || undefined; +} + /** Rotating thinking label for the given wall-clock moment; falls back to the first label. */ export function getThinkingSpinnerLabel(nowMs: number = Date.now()): string { const index = @@ -143,5 +163,5 @@ export function getThinkingSpinnerLabel(nowMs: number = Date.now()): string { /** Thinking label plus an ellipsis, for the thinking block header. */ export function formatThinkingSpinnerLabel(nowMs: number = Date.now()): string { - return `${getThinkingSpinnerLabel(nowMs)}…`; + return `${liveIntent ?? getThinkingSpinnerLabel(nowMs)}…`; } diff --git a/apps/pythinker-code/src/tui/controllers/session-event-handler.ts b/apps/pythinker-code/src/tui/controllers/session-event-handler.ts index ef66d18a..6b1ccabe 100644 --- a/apps/pythinker-code/src/tui/controllers/session-event-handler.ts +++ b/apps/pythinker-code/src/tui/controllers/session-event-handler.ts @@ -44,6 +44,7 @@ import { OAUTH_LOGIN_REQUIRED_STARTUP_NOTICE, } from '../constant/pythinker-tui'; import { FAILURE_MARK, STATUS_BULLET, SUCCESS_MARK } from '../constant/symbols'; +import { setLiveIntent } from '../constant/rendering'; import { buildGoalCompletionMessage } from '../utils/goal-completion'; import { argsRecord, @@ -166,6 +167,7 @@ export class SessionEventHandler { >(); resetRuntimeState(): void { + setLiveIntent(undefined); this.backgroundTasks.clear(); this.backgroundTaskTranscriptedTerminal.clear(); this.subAgentEventHandler.resetRuntimeState(); @@ -361,6 +363,7 @@ export class SessionEventHandler { // --------------------------------------------------------------------------- private handleTurnBegin(_event: TurnStartedEvent): void { + setLiveIntent(undefined); void _event; this.currentTurnHasAssistantText = false; // Throughput belongs to the finished turn; clear it so a stale t/s rate @@ -402,6 +405,7 @@ export class SessionEventHandler { } private handleTurnEnd(event: TurnEndedEvent, sendQueued: (item: QueuedMessage) => void): void { + setLiveIntent(undefined); this.host.streamingUI.flushNow(); this.host.dispatchFooter({ type: 'status.updated', @@ -423,6 +427,7 @@ export class SessionEventHandler { } private handleStepBegin(event: TurnStepStartedEvent): void { + setLiveIntent(undefined); this.host.streamingUI.flushNow(); this.host.streamingUI.setStep(event.step); this.host.streamingUI.resetToolUi(); @@ -439,6 +444,7 @@ export class SessionEventHandler { } private handleStepCompleted(event: TurnStepCompletedEvent): void { + setLiveIntent(undefined); this.host.streamingUI.flushNow(); this.maybeShowDebugTiming(event); if (event.finishReason !== 'max_tokens') return; @@ -555,6 +561,7 @@ export class SessionEventHandler { } private handleStepInterrupted(event: TurnStepInterruptedEvent): void { + setLiveIntent(undefined); this.host.streamingUI.flushNow(); this.host.streamingUI.resetToolUi(); this.host.streamingUI.finalizeLiveTextBuffers('idle'); @@ -639,6 +646,7 @@ export class SessionEventHandler { ) { return; } + if (event.intent !== undefined) setLiveIntent(event.intent); const { streamingUI } = this.host; streamingUI.flushNow(); const { turnId, step } = streamingUI.getTurnContext(); @@ -673,6 +681,8 @@ export class SessionEventHandler { const { state, streamingUI } = this.host; streamingUI.accumulateToolCallDelta(event.toolCallId, event.name, event.argumentsPart); const preview = streamingUI.getStreamingToolCallPreview(event.toolCallId); + const intent = preview?.args['i']; + if (typeof intent === 'string') setLiveIntent(intent); if ( preview !== undefined && preview.name === 'DynamicWorkflow' @@ -708,6 +718,7 @@ export class SessionEventHandler { } private handleToolResult(event: ToolResultEvent): void { + setLiveIntent(undefined); const { streamingUI } = this.host; streamingUI.flushNow(); const resultData: ToolResultBlockData = { @@ -1003,6 +1014,7 @@ export class SessionEventHandler { } private handleSessionError(event: ErrorEvent): void { + setLiveIntent(undefined); this.host.streamingUI.flushNow(); this.host.streamingUI.resetToolUi(); this.host.streamingUI.finalizeLiveTextBuffers('idle'); diff --git a/apps/pythinker-code/src/tui/controllers/streaming-ui.ts b/apps/pythinker-code/src/tui/controllers/streaming-ui.ts index 10d81743..f14edb4c 100644 --- a/apps/pythinker-code/src/tui/controllers/streaming-ui.ts +++ b/apps/pythinker-code/src/tui/controllers/streaming-ui.ts @@ -737,10 +737,12 @@ export class StreamingUIController { private flushToolCallPreview(id: string): void { const streaming = this._streamingToolCallArguments.get(id); if (streaming === undefined) return; + const args = parseStreamingArgs(streaming.argumentsText); + if (typeof args['i'] === 'string') delete args['i']; const toolCall: ToolCallBlockData = { id, name: streaming.name ?? this._activeToolCalls.get(id)?.name ?? 'Tool', - args: parseStreamingArgs(streaming.argumentsText), + args, streamingArguments: streaming.argumentsText, streamingStartedAtMs: streaming.startedAtMs, step: this._currentStep, diff --git a/apps/pythinker-code/test/tui/tool-intent-label.test.ts b/apps/pythinker-code/test/tui/tool-intent-label.test.ts new file mode 100644 index 00000000..10ec458c --- /dev/null +++ b/apps/pythinker-code/test/tui/tool-intent-label.test.ts @@ -0,0 +1,83 @@ +import type { Event } from '@pythoughts/pythinker-code-sdk'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { DEFAULT_STATUS_LINE_CONFIG } from '#/tui/config'; +import { + formatThinkingSpinnerLabel, + setLiveIntent, +} from '#/tui/constant/rendering'; +import { PythinkerTUI, type PythinkerTUIStartupInput } from '#/tui/pythinker-tui'; + +function makeStartupInput(): PythinkerTUIStartupInput { + return { + cliOptions: { + session: undefined, + continue: false, + rewindFiles: undefined, + yolo: false, + auto: false, + plan: false, + model: undefined, + outputFormat: undefined, + prompt: undefined, + skillsDirs: [], + }, + tuiConfig: { + theme: 'dark', + layout: 'inline', + copyFullResponse: false, + editorCommand: null, + notifications: { enabled: true, condition: 'unfocused' }, + upgrade: { autoInstall: true }, + statusLine: DEFAULT_STATUS_LINE_CONFIG, + }, + version: '0.0.0-test', + workDir: '/tmp/tool-intent-test', + }; +} + +afterEach(() => { + setLiveIntent(undefined); +}); + +describe('tool intent thinking label', () => { + it('uses the live intent and restores the rotating label when cleared', () => { + setLiveIntent('check failing test'); + expect(formatThinkingSpinnerLabel(0)).toBe('check failing test…'); + + setLiveIntent(undefined); + expect(formatThinkingSpinnerLabel(0)).toBe('thinking…'); + }); + + it('removes control characters before display', () => { + setLiveIntent('\u001B[31mcheck\n\u0007 failing test\u001B[0m'); + expect(formatThinkingSpinnerLabel(0)).toBe('check failing test…'); + }); + + it('sets intent from a tool delta and clears it on the result', () => { + const driver = new PythinkerTUI({} as never, makeStartupInput()); + const dispatch = (event: Event): void => + driver.sessionEventHandler.handleEvent(event, vi.fn()); + + dispatch({ + type: 'tool.call.delta', + agentId: 'main', + sessionId: 'session-1', + turnId: 1, + toolCallId: 'call-1', + name: 'echo', + argumentsPart: '{"i":"check failing test","text":"hello"}', + }); + expect(formatThinkingSpinnerLabel(0)).toBe('check failing test…'); + + dispatch({ + type: 'tool.result', + agentId: 'main', + sessionId: 'session-1', + turnId: 1, + toolCallId: 'call-1', + output: 'hello', + }); + expect(formatThinkingSpinnerLabel(0)).toBe('thinking…'); + }); +}); diff --git a/docs/configuration/config-files.md b/docs/configuration/config-files.md index 07368745..718f150d 100644 --- a/docs/configuration/config-files.md +++ b/docs/configuration/config-files.md @@ -229,11 +229,12 @@ advisor = "reviewer-model" ## `experimental` -`experimental` stores persistent overrides for experimental-feature flags. Currently, `micro_compaction` is the only user-facing entry and defaults to `true`; set it to `false` only when you need to disable automatic trimming of older large tool results. +`experimental` stores persistent overrides for experimental-feature flags. | Field | Type | Default | Description | | --- | --- | --- | --- | | `micro_compaction` | `boolean` | `true` | Trim older large tool results from context while preserving recent conversation | +| `tool_intent` | `boolean` | `true` | Ask the model to state a concise intent with each tool call and show it live in the working indicator; set `false` to return to the rotating label | ## `services` diff --git a/docs/configuration/env-vars.md b/docs/configuration/env-vars.md index c56924fc..39499bb8 100644 --- a/docs/configuration/env-vars.md +++ b/docs/configuration/env-vars.md @@ -135,6 +135,7 @@ Switches that control the behavior of subsystems such as telemetry, background t | `PYTHINKER_CODE_WORKFLOW_SIZE_GUIDELINE` | Override the advisory Dynamic Workflow size guideline injected into the tool guidance; takes higher priority than `config.toml` | `small`, `medium`, `large`, `unrestricted` | | `PYTHINKER_CODE_EXPERIMENTAL_FLAG` | Enable all registered experimental features for this process; `micro_compaction` is already enabled by default | `1`, `true`, `yes`, `on` | | `PYTHINKER_CODE_EXPERIMENTAL_MICRO_COMPACTION` | Override [`[experimental].micro_compaction`](./config-files.md#experimental) for this process | Truthy or falsy | +| `PYTHINKER_CODE_EXPERIMENTAL_TOOL_INTENT` | Override [`[experimental].tool_intent`](./config-files.md#experimental) for this process. When on (the default), each tool call carries a short model-written intent that the working indicator shows live; set a falsy value to turn it off | Truthy or falsy | | `PYTHINKER_SHELL_PATH` | Override the Git Bash path on Windows (used when auto-detection fails) | Absolute path | | `PYTHINKER_MODEL_MAX_COMPLETION_TOKENS` | Hard cap on `max_completion_tokens` per LLM step; applies to the `pythinker` provider only | Positive integer; `0` or negative disables clamping | | `PYTHINKER_MODEL_TEMPERATURE` | Sampling temperature for every request; applies to the `pythinker` provider only (global — independent of `PYTHINKER_MODEL_NAME`) | Number, e.g. `0.3` | diff --git a/packages/agent-core/src/agent/turn/index.ts b/packages/agent-core/src/agent/turn/index.ts index a839f563..cd71cba9 100644 --- a/packages/agent-core/src/agent/turn/index.ts +++ b/packages/agent-core/src/agent/turn/index.ts @@ -746,6 +746,7 @@ export class TurnFlow { this.agent.config.maxStepsPerTurn ?? loopControl?.maxStepsPerTurn; let stopForGoalBudget = false; try { + const toolIntentEnabled = this.agent.experimentalFlags.enabled('tool_intent'); const result = await runTurn({ turnId: String(turnId), signal, @@ -764,6 +765,7 @@ export class TurnFlow { log: this.agent.log, maxSteps: maxStepsPerTurn, maxRetryAttempts: loopControl?.maxRetriesPerStep, + toolIntentEnabled, recordStepUsage: async (usage) => { outputTokens += usage.output; try { @@ -1232,6 +1234,7 @@ function mapLoopEvent(event: LoopEvent, turnId: number): AgentEvent | undefined toolCallId: event.toolCallId, name: event.name, args: event.args, + intent: event.intent, description: event.description, display: event.display, }; diff --git a/packages/agent-core/src/flags/registry.ts b/packages/agent-core/src/flags/registry.ts index 16f45efc..e7469df6 100644 --- a/packages/agent-core/src/flags/registry.ts +++ b/packages/agent-core/src/flags/registry.ts @@ -20,6 +20,14 @@ export const FLAG_DEFINITIONS = [ default: true, surface: 'core', }, + { + id: 'tool_intent', + title: 'Tool intent indicator', + description: 'Ask the model to state a concise intent with each tool call and show it in the working indicator.', + env: 'PYTHINKER_CODE_EXPERIMENTAL_TOOL_INTENT', + default: true, + surface: 'core', + }, { id: 'vim_mode', title: 'Vim mode', diff --git a/packages/agent-core/src/loop/events.ts b/packages/agent-core/src/loop/events.ts index de31ab74..bcc1310c 100644 --- a/packages/agent-core/src/loop/events.ts +++ b/packages/agent-core/src/loop/events.ts @@ -61,6 +61,7 @@ export interface LoopToolCallEvent { readonly toolCallId: string; readonly name: string; readonly args: unknown; + readonly intent?: string | undefined; readonly description?: string | undefined; readonly display?: ToolInputDisplay | undefined; } diff --git a/packages/agent-core/src/loop/run-turn.ts b/packages/agent-core/src/loop/run-turn.ts index 238aa802..a973273d 100644 --- a/packages/agent-core/src/loop/run-turn.ts +++ b/packages/agent-core/src/loop/run-turn.ts @@ -40,6 +40,7 @@ export interface RunTurnInput { readonly log?: Logger | undefined; readonly maxSteps?: number | undefined; readonly maxRetryAttempts?: number; + readonly toolIntentEnabled?: boolean; readonly recordStepUsage?: | ((usage: TokenUsage) => RecordStepUsageResult | void | Promise) | undefined; @@ -57,6 +58,7 @@ export async function runTurn(input: RunTurnInput): Promise { log, maxSteps, maxRetryAttempts, + toolIntentEnabled = false, recordStepUsage: hostRecordStepUsage, } = input; let usage: TokenUsage = emptyUsage(); @@ -91,6 +93,7 @@ export async function runTurn(input: RunTurnInput): Promise { hooks, log, currentStep: steps, + toolIntentEnabled, maxRetryAttempts, recordUsage: recordStepUsage, }); diff --git a/packages/agent-core/src/loop/tool-call.ts b/packages/agent-core/src/loop/tool-call.ts index 8bfac388..789432b9 100644 --- a/packages/agent-core/src/loop/tool-call.ts +++ b/packages/agent-core/src/loop/tool-call.ts @@ -30,6 +30,7 @@ import type { LoopEventDispatcher, LoopToolCallEvent } from './events'; import type { LLM, LLMChatResponse } from './llm'; import { ToolAccesses } from './tool-access'; import { ToolScheduler, type ToolCallTask } from './tool-scheduler'; +import { extractIntentFromArgs, isIntentInjected } from './tool-intent'; import type { AuthorizeToolExecutionResult, ExecutableTool, @@ -74,6 +75,7 @@ export interface ToolCallStepContext { readonly turnId: string; readonly currentStep: number; readonly stepUuid: string; + readonly toolIntentEnabled?: boolean | undefined; } interface ToolCallBatchContext extends ToolCallStepContext { @@ -88,6 +90,7 @@ interface RunnableToolCall { readonly toolName: string; readonly tool: ExecutableTool; readonly args: unknown; + readonly intent?: string | undefined; } interface RejectedToolCall { @@ -95,6 +98,7 @@ interface RejectedToolCall { readonly toolCall: ToolCall; readonly toolName: string; readonly args: unknown; + readonly intent?: string | undefined; readonly output: string; } @@ -130,7 +134,9 @@ export async function runToolCallBatch( ): Promise { if (response.toolCalls.length === 0) return { stopTurn: false }; const batchStep: ToolCallBatchContext = { ...step, toolCalls: response.toolCalls }; - const calls = response.toolCalls.map((toolCall) => preflightToolCall(step.tools, toolCall)); + const calls = response.toolCalls.map((toolCall) => + preflightToolCall(step.tools, toolCall, step.toolIntentEnabled === true), + ); const scheduler = new ToolScheduler(); const pendingResults: Array> = []; let stopTurn = false; @@ -179,7 +185,19 @@ export async function recordUnexecutedToolCalls( ): Promise { for (const toolCall of response.toolCalls) { const parsedArgs = parseToolCallArguments(toolCall.arguments); - const args = parsedArgs.success ? parsedArgs.data : {}; + let args = parsedArgs.success ? parsedArgs.data : {}; + let intent: string | undefined; + const tool = + step.tools?.find((candidate) => candidate.name === toolCall.name) ?? + step.tools?.find((candidate) => candidate.aliases?.includes(toolCall.name) === true); + if ( + parsedArgs.success && + tool !== undefined && + step.toolIntentEnabled === true && + isIntentInjected(tool) + ) { + ({ args, intent } = extractIntentFromArgs(args)); + } if (!parsedArgs.success) { step.log?.debug('recording unexecuted tool call with unparseable arguments', { toolName: toolCall.name, @@ -197,6 +215,7 @@ export async function recordUnexecutedToolCalls( toolCallId: toolCall.id, name: toolCall.name, args, + intent, }); await step.dispatchEvent({ type: 'tool.result', @@ -214,6 +233,7 @@ export async function recordUnexecutedToolCalls( function preflightToolCall( tools: readonly ExecutableTool[] | undefined, toolCall: ToolCall, + toolIntentEnabled: boolean, ): PreflightedToolCall { const requestedName = toolCall.name; const parsedArgs = parseToolCallArguments(toolCall.arguments); @@ -244,13 +264,18 @@ function preflightToolCall( output: `Invalid args for tool "${toolName}": malformed JSON in arguments: ${parsedArgs.error}`, }; } - const validationError = validateExecutableToolArgs(tool, parsedArgs.data); + const extracted = + toolIntentEnabled && isIntentInjected(tool) + ? extractIntentFromArgs(parsedArgs.data) + : { args: parsedArgs.data, intent: undefined }; + const validationError = validateExecutableToolArgs(tool, extracted.args); if (validationError !== null) { return { kind: 'rejected', toolCall, toolName, - args: parsedArgs.data, + args: extracted.args, + intent: extracted.intent, output: `Invalid args for tool "${toolName}": ${validationError}`, }; } @@ -259,7 +284,8 @@ function preflightToolCall( toolCall: canonicalToolCall, toolName, tool, - args: parsedArgs.data, + args: extracted.args, + intent: extracted.intent, }; } @@ -781,6 +807,7 @@ async function dispatchToolCall( toolCallId: toolCall.id, name: toolName, args, + intent: call.intent, description: displayFields?.description, display: displayFields?.display, }); diff --git a/packages/agent-core/src/loop/tool-intent.ts b/packages/agent-core/src/loop/tool-intent.ts new file mode 100644 index 00000000..062820f1 --- /dev/null +++ b/packages/agent-core/src/loop/tool-intent.ts @@ -0,0 +1,64 @@ +import type { ExecutableTool } from './types'; + +export const INTENT_FIELD = 'i'; +export const INTENT_MAX_LENGTH = 120; +/** Tool names excluded from intent injection. Empty today; add names when a tool's intent is self-evident. */ +export const INTENT_OMIT_TOOLS: ReadonlySet = new Set(); + +// oxlint-disable-next-line no-control-regex -- model-authored terminal text must not retain escape sequences. +const ANSI_ESCAPE = /\u001B(?:\[[0-?]*[ -/]*[@-~]|\][^\u0007]*(?:\u0007|$))/gu; +const CONTROL_CHARACTER = /\p{Cc}/gu; + +function isPlainRecord(value: unknown): value is Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value) as unknown; + return prototype === Object.prototype || prototype === null; +} + +export function isIntentInjected(tool: ExecutableTool): boolean { + if (INTENT_OMIT_TOOLS.has(tool.name) || !isPlainRecord(tool.parameters)) return false; + const properties = tool.parameters['properties']; + return isPlainRecord(properties) && !Object.hasOwn(properties, INTENT_FIELD); +} + +export function injectIntentIntoTools(tools: readonly ExecutableTool[]): ExecutableTool[] { + return tools.map((tool) => { + if (!isIntentInjected(tool)) return tool; + const schema = tool.parameters; + const properties = schema['properties'] as Record; + const required = Array.isArray(schema['required']) ? schema['required'] : []; + return { + ...tool, + parameters: { + ...schema, + properties: { + [INTENT_FIELD]: { type: 'string', description: 'concise intent' }, + ...properties, + }, + required: [INTENT_FIELD, ...required], + }, + resolveExecution: tool.resolveExecution.bind(tool), + }; + }); +} + +export function extractIntentFromArgs(args: unknown): { + args: unknown; + intent: string | undefined; +} { + if (!isPlainRecord(args) || typeof args[INTENT_FIELD] !== 'string') { + return { args, intent: undefined }; + } + const { [INTENT_FIELD]: rawIntent, ...rest } = args; + return { args: rest, intent: sanitizeIntent(rawIntent as string) }; +} + +export function sanitizeIntent(raw: string): string | undefined { + const normalized = raw + .replaceAll(ANSI_ESCAPE, '') + .replaceAll(CONTROL_CHARACTER, ' ') + .replaceAll(/\s+/gu, ' ') + .trim(); + const capped = Array.from(normalized).slice(0, INTENT_MAX_LENGTH).join('').trimEnd(); + return capped.length > 0 ? capped : undefined; +} diff --git a/packages/agent-core/src/loop/turn-step.ts b/packages/agent-core/src/loop/turn-step.ts index 1e1dd1a4..fe2accc6 100644 --- a/packages/agent-core/src/loop/turn-step.ts +++ b/packages/agent-core/src/loop/turn-step.ts @@ -15,6 +15,7 @@ import type { Logger } from '#/logging/types'; import type { LoopEventDispatcher } from './events'; import type { LLM, LLMChatParams, LLMChatResponse } from './llm'; import { chatWithRetry } from './retry'; +import { injectIntentIntoTools } from './tool-intent'; import { recordUnexecutedToolCalls, runToolCallBatch, @@ -43,6 +44,7 @@ export interface ExecuteLoopStepDeps { readonly hooks?: LoopHooks | undefined; readonly log?: Logger | undefined; readonly currentStep: number; + readonly toolIntentEnabled: boolean; readonly maxRetryAttempts?: number; readonly recordUsage: (usage: TokenUsage) => RecordStepUsageResult | void | Promise; } @@ -61,6 +63,7 @@ export async function executeLoopStep(deps: ExecuteLoopStepDeps): Promise<{ hooks, log, currentStep, + toolIntentEnabled, maxRetryAttempts, recordUsage, } = deps; @@ -94,6 +97,7 @@ export async function executeLoopStep(deps: ExecuteLoopStepDeps): Promise<{ turnId, currentStep, stepUuid, + toolIntentEnabled, }; await dispatchEvent({ @@ -105,7 +109,7 @@ export async function executeLoopStep(deps: ExecuteLoopStepDeps): Promise<{ const chatParams: LLMChatParams = { messages, - tools: tools ?? [], + tools: toolIntentEnabled ? injectIntentIntoTools(tools ?? []) : (tools ?? []), signal, ...createChatStreamingCallbacks({ dispatchEvent, diff --git a/packages/agent-core/test/loop/tool-call.e2e.test.ts b/packages/agent-core/test/loop/tool-call.e2e.test.ts index 2680ec39..5e155e7c 100644 --- a/packages/agent-core/test/loop/tool-call.e2e.test.ts +++ b/packages/agent-core/test/loop/tool-call.e2e.test.ts @@ -11,7 +11,7 @@ import type { ContentPart } from '@pythoughts/kosong'; import { describe, expect, it } from 'vitest'; -import { ToolAccesses } from '../../src/loop'; +import { createLoopEventDispatcher, runTurn as runTurnImpl, ToolAccesses } from '../../src/loop'; import type { Logger } from '../../src/logging'; import type { ExecutableTool, @@ -28,8 +28,11 @@ import { makeThinkingParts, makeToolCall, makeToolUseResponse, + FakeLLM, } from './fixtures/fake-llm'; import { runTurn } from './fixtures/helpers'; +import { CollectingSink } from './fixtures/collecting-sink'; +import { RecordingContext } from './fixtures/recording-context'; import { ContentBlocksTool, EchoTool, @@ -65,6 +68,41 @@ function waitOneMacrotask(): Promise { }); } +async function runToolIntentTurn( + toolIntentEnabled: boolean, + hooks?: LoopHooks, +): Promise<{ + strict: StrictArgsTool; + sink: CollectingSink; + context: RecordingContext; +}> { + const strict = new StrictArgsTool(); + const sink = new CollectingSink(); + const context = new RecordingContext(); + const llm = new FakeLLM({ + responses: [ + makeToolUseResponse([ + makeToolCall('strict', { i: 'do the thing', value: 7 }, 'tc-intent'), + ]), + makeEndTurnResponse('done'), + ], + }); + await runTurnImpl({ + turnId: 'turn-intent', + signal: new AbortController().signal, + llm, + buildMessages: context.buildMessages, + dispatchEvent: createLoopEventDispatcher({ + appendTranscriptRecord: context.appendTranscriptRecord, + emitLiveEvent: sink.emit, + }), + tools: [strict], + hooks, + toolIntentEnabled, + }); + return { strict, sink, context }; +} + function makeTestLogger(): { readonly log: Logger; readonly entries: Array<{ readonly level: string; readonly message: string; readonly payload: unknown }>; @@ -81,6 +119,45 @@ function makeTestLogger(): { } describe('runTurn — tool-call behaviour', () => { + it('strips enabled intent before hooks, validation, execution, and persistence', async () => { + const hookArgs: unknown[] = []; + const hooks: LoopHooks = { + prepareToolExecution: async (context) => { + hookArgs.push(context.args); + return undefined; + }, + authorizeToolExecution: async (context) => { + hookArgs.push(context.args); + return undefined; + }, + }; + + const { strict, sink, context } = await runToolIntentTurn(true, hooks); + + expect(strict.calls[0]?.args).toEqual({ value: 7 }); + expect(hookArgs).toEqual([{ value: 7 }, { value: 7 }]); + expect(sink.byType('tool.call')[0]).toMatchObject({ + args: { value: 7 }, + intent: 'do the thing', + }); + expect(context.toolCalls()[0]).toMatchObject({ + args: { value: 7 }, + intent: 'do the thing', + }); + }); + + it('keeps intent in args when the feature is disabled', async () => { + const { strict, sink } = await runToolIntentTurn(false); + + expect(strict.calls).toHaveLength(0); + expect(sink.byType('tool.call')[0]).toMatchObject({ + args: { i: 'do the thing', value: 7 }, + }); + expect(sink.byType('tool.call')[0]?.intent).toBeUndefined(); + expect(sink.byType('tool.result')[0]?.result.isError).toBe(true); + expect(sink.byType('tool.result')[0]?.result.output).toContain('Invalid args'); + }); + it('routes a successful tool call through execute and emits paired events', async () => { const echo = new EchoTool(); const { sink, context } = await runTurn({ diff --git a/packages/agent-core/test/loop/tool-intent.test.ts b/packages/agent-core/test/loop/tool-intent.test.ts new file mode 100644 index 00000000..be770e00 --- /dev/null +++ b/packages/agent-core/test/loop/tool-intent.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'vitest'; + +import type { ExecutableTool } from '../../src/loop/types'; +import { + extractIntentFromArgs, + injectIntentIntoTools, + INTENT_FIELD, + INTENT_MAX_LENGTH, + INTENT_OMIT_TOOLS, + sanitizeIntent, +} from '../../src/loop/tool-intent'; + +function makeTool( + name = 'test', + parameters: Record = { + type: 'object', + properties: { value: { type: 'string' } }, + required: ['value'], + }, +): ExecutableTool { + return { + name, + description: 'Test tool.', + parameters, + resolveExecution: () => ({ + approvalRule: name, + execute: () => Promise.resolve({ output: 'ok' }), + }), + }; +} + +describe('tool intent schema injection', () => { + it('clones the tool and schema with intent first without mutating the original', () => { + const parameters = { + type: 'object', + properties: { value: { type: 'string' } }, + required: ['value'], + }; + const originalValue = structuredClone(parameters); + const tool = makeTool('test', parameters); + + const [injected] = injectIntentIntoTools([tool]); + + expect(injected).not.toBe(tool); + expect(injected?.parameters).not.toBe(parameters); + expect(tool.parameters).toBe(parameters); + expect(parameters).toEqual(originalValue); + const properties = injected?.parameters['properties'] as Record; + expect(Object.keys(properties)[0]).toBe(INTENT_FIELD); + expect(injected?.parameters['required']).toEqual([INTENT_FIELD, 'value']); + }); + + it('returns omitted tools unchanged', () => { + const omittedTools = INTENT_OMIT_TOOLS as Set; + omittedTools.add('omitted'); + try { + const tool = makeTool('omitted'); + expect(injectIntentIntoTools([tool])[0]).toBe(tool); + } finally { + omittedTools.delete('omitted'); + } + }); + + it('returns tools with an intent collision unchanged', () => { + const tool = makeTool('collision', { + type: 'object', + properties: { i: { type: 'number' } }, + }); + expect(injectIntentIntoTools([tool])[0]).toBe(tool); + }); + + it('returns tools with a non-object root unchanged', () => { + const tool = makeTool('array-root', [] as unknown as Record); + expect(injectIntentIntoTools([tool])[0]).toBe(tool); + }); +}); + +describe('tool intent extraction', () => { + it('removes a string intent and returns its sanitized value', () => { + expect(extractIntentFromArgs({ i: ' check test ', value: 1 })).toEqual({ + args: { value: 1 }, + intent: 'check test', + }); + }); + + it('passes non-object args through', () => { + const args = ['value']; + expect(extractIntentFromArgs(args)).toEqual({ args, intent: undefined }); + }); + + it('passes args without a string intent through', () => { + const args = { value: 1 }; + expect(extractIntentFromArgs(args)).toEqual({ args, intent: undefined }); + }); +}); + +describe('tool intent sanitization', () => { + it('strips terminal escapes and controls and collapses whitespace', () => { + expect(sanitizeIntent('\u001B[31mcheck\n\u0007 failing\u001B[0m')).toBe('check failing'); + }); + + it('caps the result by code points', () => { + const sanitized = sanitizeIntent('🙂'.repeat(INTENT_MAX_LENGTH + 1)); + expect(Array.from(sanitized ?? '')).toHaveLength(INTENT_MAX_LENGTH); + }); + + it('returns undefined for an empty result', () => { + expect(sanitizeIntent('\u001B[31m\u001B[0m\u0007')).toBeUndefined(); + }); +}); diff --git a/packages/protocol/src/__tests__/events.test.ts b/packages/protocol/src/__tests__/events.test.ts index d8c086fc..2a8743fc 100644 --- a/packages/protocol/src/__tests__/events.test.ts +++ b/packages/protocol/src/__tests__/events.test.ts @@ -84,6 +84,7 @@ describe('events / display re-exports', () => { toolCallId: 'call_1', name: 'bash', args: { command: 'pwd' }, + intent: 'check cwd', display: { kind: 'command', command: 'pwd', language: 'bash' }, }).success, ).toBe(true); diff --git a/packages/protocol/src/events.ts b/packages/protocol/src/events.ts index 51785e6c..4f2cd967 100644 --- a/packages/protocol/src/events.ts +++ b/packages/protocol/src/events.ts @@ -488,6 +488,7 @@ export interface ToolCallStartedEvent { readonly toolCallId: string; readonly name: string; readonly args: unknown; + readonly intent?: string; readonly description?: string; readonly display?: ToolInputDisplay; } @@ -1179,6 +1180,7 @@ export const toolCallStartedEventSchema = z.object({ toolCallId: z.string(), name: z.string(), args: z.unknown(), + intent: z.string().optional(), description: z.string().optional(), display: ToolInputDisplaySchema.optional(), }) satisfies z.ZodType; From 3102d2efb16a94b83d7bd850023a78fdbb9d3813 Mon Sep 17 00:00:00 2001 From: elkaix Date: Tue, 11 Aug 2026 18:55:25 -0400 Subject: [PATCH 2/6] fix: omit StructuredOutput from tool intent injection StructuredOutput is mechanical and its consumers treat the advertised schema as an exact contract. --- packages/agent-core/src/loop/tool-intent.ts | 4 ++-- packages/agent-core/test/loop/tool-intent.test.ts | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/agent-core/src/loop/tool-intent.ts b/packages/agent-core/src/loop/tool-intent.ts index 062820f1..a8dee95b 100644 --- a/packages/agent-core/src/loop/tool-intent.ts +++ b/packages/agent-core/src/loop/tool-intent.ts @@ -2,8 +2,8 @@ import type { ExecutableTool } from './types'; export const INTENT_FIELD = 'i'; export const INTENT_MAX_LENGTH = 120; -/** Tool names excluded from intent injection. Empty today; add names when a tool's intent is self-evident. */ -export const INTENT_OMIT_TOOLS: ReadonlySet = new Set(); +/** Tool names excluded from intent injection. StructuredOutput is mechanical and has an exact schema contract. */ +export const INTENT_OMIT_TOOLS: ReadonlySet = new Set(['StructuredOutput']); // oxlint-disable-next-line no-control-regex -- model-authored terminal text must not retain escape sequences. const ANSI_ESCAPE = /\u001B(?:\[[0-?]*[ -/]*[@-~]|\][^\u0007]*(?:\u0007|$))/gu; diff --git a/packages/agent-core/test/loop/tool-intent.test.ts b/packages/agent-core/test/loop/tool-intent.test.ts index be770e00..ab694896 100644 --- a/packages/agent-core/test/loop/tool-intent.test.ts +++ b/packages/agent-core/test/loop/tool-intent.test.ts @@ -61,6 +61,11 @@ describe('tool intent schema injection', () => { } }); + it('returns StructuredOutput unchanged', () => { + const tool = makeTool('StructuredOutput'); + expect(injectIntentIntoTools([tool])[0]).toBe(tool); + }); + it('returns tools with an intent collision unchanged', () => { const tool = makeTool('collision', { type: 'object', From d22ecb0fdcb4e75fefab32c6bfb1adf384bbb4fe Mon Sep 17 00:00:00 2001 From: elkaix Date: Tue, 11 Aug 2026 20:30:11 -0400 Subject: [PATCH 3/6] fix: address review feedback on tool intent --- .changeset/tool-intent-indicator.md | 2 +- .../tui/controllers/session-event-handler.ts | 4 +-- .../test/tui/tool-intent-label.test.ts | 28 +++++++++++++++++++ docs/configuration/config-files.md | 2 +- docs/configuration/env-vars.md | 2 +- packages/agent-core/src/loop/tool-intent.ts | 4 +-- .../agent-core/test/loop/tool-intent.test.ts | 18 ++++++++++++ 7 files changed, 53 insertions(+), 7 deletions(-) diff --git a/.changeset/tool-intent-indicator.md b/.changeset/tool-intent-indicator.md index c93ff013..dfa06f32 100644 --- a/.changeset/tool-intent-indicator.md +++ b/.changeset/tool-intent-indicator.md @@ -2,4 +2,4 @@ "@pythoughts/pythinker-code": minor --- -Show what the agent is doing in the working indicator: each tool call now carries a short model-written intent, streamed live into the spinner label (for example "check failing test…") instead of a random verb; disable with `PYTHINKER_CODE_EXPERIMENTAL_TOOL_INTENT=0`. +Show what the agent is doing in the working indicator: eligible tool calls whose input schema accepts the injected field now carry a short model-written intent, streamed live into the spinner label (for example "check failing test…") instead of a random verb; disable with `PYTHINKER_CODE_EXPERIMENTAL_TOOL_INTENT=0`. diff --git a/apps/pythinker-code/src/tui/controllers/session-event-handler.ts b/apps/pythinker-code/src/tui/controllers/session-event-handler.ts index 6b1ccabe..4ff42b32 100644 --- a/apps/pythinker-code/src/tui/controllers/session-event-handler.ts +++ b/apps/pythinker-code/src/tui/controllers/session-event-handler.ts @@ -646,7 +646,7 @@ export class SessionEventHandler { ) { return; } - if (event.intent !== undefined) setLiveIntent(event.intent); + setLiveIntent(event.intent); const { streamingUI } = this.host; streamingUI.flushNow(); const { turnId, step } = streamingUI.getTurnContext(); @@ -682,7 +682,7 @@ export class SessionEventHandler { streamingUI.accumulateToolCallDelta(event.toolCallId, event.name, event.argumentsPart); const preview = streamingUI.getStreamingToolCallPreview(event.toolCallId); const intent = preview?.args['i']; - if (typeof intent === 'string') setLiveIntent(intent); + setLiveIntent(typeof intent === 'string' ? intent : undefined); if ( preview !== undefined && preview.name === 'DynamicWorkflow' diff --git a/apps/pythinker-code/test/tui/tool-intent-label.test.ts b/apps/pythinker-code/test/tui/tool-intent-label.test.ts index 10ec458c..d366167a 100644 --- a/apps/pythinker-code/test/tui/tool-intent-label.test.ts +++ b/apps/pythinker-code/test/tui/tool-intent-label.test.ts @@ -80,4 +80,32 @@ describe('tool intent thinking label', () => { }); expect(formatThinkingSpinnerLabel(0)).toBe('thinking…'); }); + + it('clears a stale intent when the next tool call has no intent', () => { + const driver = new PythinkerTUI({} as never, makeStartupInput()); + const dispatch = (event: Event): void => + driver.sessionEventHandler.handleEvent(event, vi.fn()); + + dispatch({ + type: 'tool.call.started', + agentId: 'main', + sessionId: 'session-1', + turnId: 1, + toolCallId: 'call-1', + name: 'echo', + args: {}, + intent: 'check failing test', + }); + dispatch({ + type: 'tool.call.started', + agentId: 'main', + sessionId: 'session-1', + turnId: 1, + toolCallId: 'call-2', + name: 'StructuredOutput', + args: {}, + }); + + expect(formatThinkingSpinnerLabel(0)).toBe('thinking…'); + }); }); diff --git a/docs/configuration/config-files.md b/docs/configuration/config-files.md index 718f150d..0aef4e57 100644 --- a/docs/configuration/config-files.md +++ b/docs/configuration/config-files.md @@ -234,7 +234,7 @@ advisor = "reviewer-model" | Field | Type | Default | Description | | --- | --- | --- | --- | | `micro_compaction` | `boolean` | `true` | Trim older large tool results from context while preserving recent conversation | -| `tool_intent` | `boolean` | `true` | Ask the model to state a concise intent with each tool call and show it live in the working indicator; set `false` to return to the rotating label | +| `tool_intent` | `boolean` | `true` | Ask the model to state a concise intent with eligible tool calls whose input schema accepts the injected field and show it live in the working indicator; set `false` to return to the rotating label | ## `services` diff --git a/docs/configuration/env-vars.md b/docs/configuration/env-vars.md index 39499bb8..6c603e4d 100644 --- a/docs/configuration/env-vars.md +++ b/docs/configuration/env-vars.md @@ -135,7 +135,7 @@ Switches that control the behavior of subsystems such as telemetry, background t | `PYTHINKER_CODE_WORKFLOW_SIZE_GUIDELINE` | Override the advisory Dynamic Workflow size guideline injected into the tool guidance; takes higher priority than `config.toml` | `small`, `medium`, `large`, `unrestricted` | | `PYTHINKER_CODE_EXPERIMENTAL_FLAG` | Enable all registered experimental features for this process; `micro_compaction` is already enabled by default | `1`, `true`, `yes`, `on` | | `PYTHINKER_CODE_EXPERIMENTAL_MICRO_COMPACTION` | Override [`[experimental].micro_compaction`](./config-files.md#experimental) for this process | Truthy or falsy | -| `PYTHINKER_CODE_EXPERIMENTAL_TOOL_INTENT` | Override [`[experimental].tool_intent`](./config-files.md#experimental) for this process. When on (the default), each tool call carries a short model-written intent that the working indicator shows live; set a falsy value to turn it off | Truthy or falsy | +| `PYTHINKER_CODE_EXPERIMENTAL_TOOL_INTENT` | Override [`[experimental].tool_intent`](./config-files.md#experimental) for this process. When on (the default), eligible tool calls whose input schema accepts the injected field carry a short model-written intent that the working indicator shows live; set a falsy value to turn it off | Truthy or falsy | | `PYTHINKER_SHELL_PATH` | Override the Git Bash path on Windows (used when auto-detection fails) | Absolute path | | `PYTHINKER_MODEL_MAX_COMPLETION_TOKENS` | Hard cap on `max_completion_tokens` per LLM step; applies to the `pythinker` provider only | Positive integer; `0` or negative disables clamping | | `PYTHINKER_MODEL_TEMPERATURE` | Sampling temperature for every request; applies to the `pythinker` provider only (global — independent of `PYTHINKER_MODEL_NAME`) | Number, e.g. `0.3` | diff --git a/packages/agent-core/src/loop/tool-intent.ts b/packages/agent-core/src/loop/tool-intent.ts index a8dee95b..4534bddd 100644 --- a/packages/agent-core/src/loop/tool-intent.ts +++ b/packages/agent-core/src/loop/tool-intent.ts @@ -6,7 +6,7 @@ export const INTENT_MAX_LENGTH = 120; export const INTENT_OMIT_TOOLS: ReadonlySet = new Set(['StructuredOutput']); // oxlint-disable-next-line no-control-regex -- model-authored terminal text must not retain escape sequences. -const ANSI_ESCAPE = /\u001B(?:\[[0-?]*[ -/]*[@-~]|\][^\u0007]*(?:\u0007|$))/gu; +const ANSI_ESCAPE = /\u001B(?:\[[0-?]*[ -/]*[@-~]|\][^\u0007\u001B]*(?:\u0007|\u001B\\|$))/gu; const CONTROL_CHARACTER = /\p{Cc}/gu; function isPlainRecord(value: unknown): value is Record { @@ -35,7 +35,7 @@ export function injectIntentIntoTools(tools: readonly ExecutableTool[]): Executa [INTENT_FIELD]: { type: 'string', description: 'concise intent' }, ...properties, }, - required: [INTENT_FIELD, ...required], + required: [INTENT_FIELD, ...required.filter((entry) => entry !== INTENT_FIELD)], }, resolveExecution: tool.resolveExecution.bind(tool), }; diff --git a/packages/agent-core/test/loop/tool-intent.test.ts b/packages/agent-core/test/loop/tool-intent.test.ts index ab694896..3d5b14a3 100644 --- a/packages/agent-core/test/loop/tool-intent.test.ts +++ b/packages/agent-core/test/loop/tool-intent.test.ts @@ -50,6 +50,18 @@ describe('tool intent schema injection', () => { expect(injected?.parameters['required']).toEqual([INTENT_FIELD, 'value']); }); + it('does not duplicate intent in required', () => { + const tool = makeTool('test', { + type: 'object', + properties: { value: { type: 'string' } }, + required: [INTENT_FIELD], + }); + + const [injected] = injectIntentIntoTools([tool]); + + expect(injected?.parameters['required']).toEqual([INTENT_FIELD]); + }); + it('returns omitted tools unchanged', () => { const omittedTools = INTENT_OMIT_TOOLS as Set; omittedTools.add('omitted'); @@ -104,6 +116,12 @@ describe('tool intent sanitization', () => { expect(sanitizeIntent('\u001B[31mcheck\n\u0007 failing\u001B[0m')).toBe('check failing'); }); + it('preserves visible text after an ST-terminated OSC hyperlink', () => { + expect(sanitizeIntent('\u001B]8;;https://example.com\u001B\\click\u001B]8;;\u001B\\ done')).toBe( + 'click done', + ); + }); + it('caps the result by code points', () => { const sanitized = sanitizeIntent('🙂'.repeat(INTENT_MAX_LENGTH + 1)); expect(Array.from(sanitized ?? '')).toHaveLength(INTENT_MAX_LENGTH); From 244ebf520e2e71dd3780608b1be1bfb4233128b7 Mon Sep 17 00:00:00 2001 From: elkaix Date: Tue, 11 Aug 2026 21:46:42 -0400 Subject: [PATCH 4/6] fix: stream tool intent live and sync the ANSI sanitizer --- apps/pythinker-code/src/tui/constant/rendering.ts | 2 +- apps/pythinker-code/src/tui/constant/streaming.ts | 2 +- apps/pythinker-code/test/tui/tool-intent-label.test.ts | 7 +++++++ apps/pythinker-code/test/tui/utils/event-payload.test.ts | 6 ++++++ 4 files changed, 15 insertions(+), 2 deletions(-) diff --git a/apps/pythinker-code/src/tui/constant/rendering.ts b/apps/pythinker-code/src/tui/constant/rendering.ts index 0d823fba..43548a03 100644 --- a/apps/pythinker-code/src/tui/constant/rendering.ts +++ b/apps/pythinker-code/src/tui/constant/rendering.ts @@ -134,7 +134,7 @@ export const THINKING_SPINNER_LABEL_INTERVAL_MS = 12_000; const LIVE_INTENT_MAX_LENGTH = 120; // oxlint-disable-next-line no-control-regex -- wire text must not retain terminal escape sequences. -const ANSI_ESCAPE = /\u001B(?:\[[0-?]*[ -/]*[@-~]|\][^\u0007]*(?:\u0007|$))/gu; +const ANSI_ESCAPE = /\u001B(?:\[[0-?]*[ -/]*[@-~]|\][^\u0007\u001B]*(?:\u0007|\u001B\\|$))/gu; const CONTROL_CHARACTER = /\p{Cc}/gu; let liveIntent: string | undefined; diff --git a/apps/pythinker-code/src/tui/constant/streaming.ts b/apps/pythinker-code/src/tui/constant/streaming.ts index b8f88a01..110f8584 100644 --- a/apps/pythinker-code/src/tui/constant/streaming.ts +++ b/apps/pythinker-code/src/tui/constant/streaming.ts @@ -1,7 +1,7 @@ // Extracts useful string fields from partially streamed JSON tool args. // This is intentionally a preview parser, not a full JSON parser. export const STREAMING_ARGS_FIELD_RE = - /"(path|file_path|command|pattern|query|url|description|title|name)"\s*:\s*"((?:\\.|[^"\\])*)"/g; + /"(i|path|file_path|command|pattern|query|url|description|title|name)"\s*:\s*"((?:\\.|[^"\\])*)"/g; // Bounds live tool-argument previews; final tool.call payloads remain complete. export const STREAMING_ARGS_PREVIEW_MAX_CHARS = 64 * 1024; diff --git a/apps/pythinker-code/test/tui/tool-intent-label.test.ts b/apps/pythinker-code/test/tui/tool-intent-label.test.ts index d366167a..a216d2d6 100644 --- a/apps/pythinker-code/test/tui/tool-intent-label.test.ts +++ b/apps/pythinker-code/test/tui/tool-intent-label.test.ts @@ -54,6 +54,13 @@ describe('tool intent thinking label', () => { expect(formatThinkingSpinnerLabel(0)).toBe('check failing test…'); }); + it('removes ST-terminated OSC hyperlinks before display', () => { + setLiveIntent( + '\u001B]8;;https://example.com\u001B\\click\u001B]8;;\u001B\\ done', + ); + expect(formatThinkingSpinnerLabel(0)).toBe('click done…'); + }); + it('sets intent from a tool delta and clears it on the result', () => { const driver = new PythinkerTUI({} as never, makeStartupInput()); const dispatch = (event: Event): void => diff --git a/apps/pythinker-code/test/tui/utils/event-payload.test.ts b/apps/pythinker-code/test/tui/utils/event-payload.test.ts index e3af0421..7f062592 100644 --- a/apps/pythinker-code/test/tui/utils/event-payload.test.ts +++ b/apps/pythinker-code/test/tui/utils/event-payload.test.ts @@ -18,6 +18,12 @@ describe('streaming tool argument payload helpers', () => { }); }); + it('parses intent from partial streaming arguments', () => { + expect(parseStreamingArgs('{"i":"scan configs","path":"/tmp/x')).toMatchObject({ + i: 'scan configs', + }); + }); + it('caps accumulated streaming preview text', () => { const current = 'a'.repeat(STREAMING_ARGS_PREVIEW_MAX_CHARS - 2); From e5ff2c32fc92e7340b3675748c1343aa7868e512 Mon Sep 17 00:00:00 2001 From: elkaix Date: Tue, 11 Aug 2026 22:50:53 -0400 Subject: [PATCH 5/6] fix: clear live tool intent when a step retries A stale intent label survived into the retried step. Also guard the deliberately duplicated ANSI sanitizer with mirrored test fixtures on both sides so future drift breaks a test. --- .changeset/tool-intent-indicator.md | 2 +- .../src/tui/constant/rendering.ts | 1 + .../tui/controllers/session-event-handler.ts | 2 +- .../test/tui/tool-intent-label.test.ts | 52 +++++++++++++++---- packages/agent-core/src/loop/tool-intent.ts | 1 + .../agent-core/test/loop/tool-intent.test.ts | 17 +++--- 6 files changed, 55 insertions(+), 20 deletions(-) diff --git a/.changeset/tool-intent-indicator.md b/.changeset/tool-intent-indicator.md index dfa06f32..a88e693a 100644 --- a/.changeset/tool-intent-indicator.md +++ b/.changeset/tool-intent-indicator.md @@ -2,4 +2,4 @@ "@pythoughts/pythinker-code": minor --- -Show what the agent is doing in the working indicator: eligible tool calls whose input schema accepts the injected field now carry a short model-written intent, streamed live into the spinner label (for example "check failing test…") instead of a random verb; disable with `PYTHINKER_CODE_EXPERIMENTAL_TOOL_INTENT=0`. +Show what the agent is doing in the working indicator: eligible tool calls whose input schema accepts the injected field now carry a short model-written intent, streamed live into the spinner label (for example "check failing test…") instead of a rotating placeholder; disable with `PYTHINKER_CODE_EXPERIMENTAL_TOOL_INTENT=0`. diff --git a/apps/pythinker-code/src/tui/constant/rendering.ts b/apps/pythinker-code/src/tui/constant/rendering.ts index 43548a03..837e504b 100644 --- a/apps/pythinker-code/src/tui/constant/rendering.ts +++ b/apps/pythinker-code/src/tui/constant/rendering.ts @@ -133,6 +133,7 @@ export const THINKING_SPINNER_LABELS = [ export const THINKING_SPINNER_LABEL_INTERVAL_MS = 12_000; const LIVE_INTENT_MAX_LENGTH = 120; +// Keep in sync with packages/agent-core/src/loop/tool-intent.ts. // oxlint-disable-next-line no-control-regex -- wire text must not retain terminal escape sequences. const ANSI_ESCAPE = /\u001B(?:\[[0-?]*[ -/]*[@-~]|\][^\u0007\u001B]*(?:\u0007|\u001B\\|$))/gu; const CONTROL_CHARACTER = /\p{Cc}/gu; diff --git a/apps/pythinker-code/src/tui/controllers/session-event-handler.ts b/apps/pythinker-code/src/tui/controllers/session-event-handler.ts index 4ff42b32..474e41a1 100644 --- a/apps/pythinker-code/src/tui/controllers/session-event-handler.ts +++ b/apps/pythinker-code/src/tui/controllers/session-event-handler.ts @@ -287,7 +287,7 @@ export class SessionEventHandler { case 'turn.step.started': this.handleStepBegin(event); break; case 'turn.step.interrupted': this.handleStepInterrupted(event); break; case 'turn.step.completed': this.handleStepCompleted(event); break; - case 'turn.step.retrying': break; + case 'turn.step.retrying': setLiveIntent(undefined); break; case 'tool.progress': this.handleToolProgress(event); break; case 'assistant.delta': this.handleAssistantDelta(event); break; case 'hook.result': this.handleHookResult(event); break; diff --git a/apps/pythinker-code/test/tui/tool-intent-label.test.ts b/apps/pythinker-code/test/tui/tool-intent-label.test.ts index a216d2d6..4a5535cb 100644 --- a/apps/pythinker-code/test/tui/tool-intent-label.test.ts +++ b/apps/pythinker-code/test/tui/tool-intent-label.test.ts @@ -8,6 +8,13 @@ import { } from '#/tui/constant/rendering'; import { PythinkerTUI, type PythinkerTUIStartupInput } from '#/tui/pythinker-tui'; +const SANITIZER_FIXTURES = [ + ['\u001B[31mred\u001B[0m', 'red'], + ['\u001B]0;title\u0007visible', 'visible'], + ['\u001B]0;title\u001B\\visible', 'visible'], + ['check\n\u0007test', 'check test'], +] as const; + function makeStartupInput(): PythinkerTUIStartupInput { return { cliOptions: { @@ -49,16 +56,9 @@ describe('tool intent thinking label', () => { expect(formatThinkingSpinnerLabel(0)).toBe('thinking…'); }); - it('removes control characters before display', () => { - setLiveIntent('\u001B[31mcheck\n\u0007 failing test\u001B[0m'); - expect(formatThinkingSpinnerLabel(0)).toBe('check failing test…'); - }); - - it('removes ST-terminated OSC hyperlinks before display', () => { - setLiveIntent( - '\u001B]8;;https://example.com\u001B\\click\u001B]8;;\u001B\\ done', - ); - expect(formatThinkingSpinnerLabel(0)).toBe('click done…'); + it.each(SANITIZER_FIXTURES)('sanitizes intent %j', (raw, expected) => { + setLiveIntent(raw); + expect(formatThinkingSpinnerLabel(0)).toBe(`${expected}…`); }); it('sets intent from a tool delta and clears it on the result', () => { @@ -115,4 +115,36 @@ describe('tool intent thinking label', () => { expect(formatThinkingSpinnerLabel(0)).toBe('thinking…'); }); + + it('clears the live intent when a step retries', () => { + const driver = new PythinkerTUI({} as never, makeStartupInput()); + const dispatch = (event: Event): void => + driver.sessionEventHandler.handleEvent(event, vi.fn()); + + dispatch({ + type: 'tool.call.started', + agentId: 'main', + sessionId: 'session-1', + turnId: 1, + toolCallId: 'call-1', + name: 'echo', + args: {}, + intent: 'check failing test', + }); + dispatch({ + type: 'turn.step.retrying', + agentId: 'main', + sessionId: 'session-1', + turnId: 1, + step: 1, + failedAttempt: 1, + nextAttempt: 2, + maxAttempts: 3, + delayMs: 100, + errorName: 'Error', + errorMessage: 'retry', + }); + + expect(formatThinkingSpinnerLabel(0)).toBe('thinking…'); + }); }); diff --git a/packages/agent-core/src/loop/tool-intent.ts b/packages/agent-core/src/loop/tool-intent.ts index 4534bddd..b9cfe60e 100644 --- a/packages/agent-core/src/loop/tool-intent.ts +++ b/packages/agent-core/src/loop/tool-intent.ts @@ -5,6 +5,7 @@ export const INTENT_MAX_LENGTH = 120; /** Tool names excluded from intent injection. StructuredOutput is mechanical and has an exact schema contract. */ export const INTENT_OMIT_TOOLS: ReadonlySet = new Set(['StructuredOutput']); +// Keep in sync with apps/pythinker-code/src/tui/constant/rendering.ts. // oxlint-disable-next-line no-control-regex -- model-authored terminal text must not retain escape sequences. const ANSI_ESCAPE = /\u001B(?:\[[0-?]*[ -/]*[@-~]|\][^\u0007\u001B]*(?:\u0007|\u001B\\|$))/gu; const CONTROL_CHARACTER = /\p{Cc}/gu; diff --git a/packages/agent-core/test/loop/tool-intent.test.ts b/packages/agent-core/test/loop/tool-intent.test.ts index 3d5b14a3..fb00dae1 100644 --- a/packages/agent-core/test/loop/tool-intent.test.ts +++ b/packages/agent-core/test/loop/tool-intent.test.ts @@ -10,6 +10,13 @@ import { sanitizeIntent, } from '../../src/loop/tool-intent'; +const SANITIZER_FIXTURES = [ + ['\u001B[31mred\u001B[0m', 'red'], + ['\u001B]0;title\u0007visible', 'visible'], + ['\u001B]0;title\u001B\\visible', 'visible'], + ['check\n\u0007test', 'check test'], +] as const; + function makeTool( name = 'test', parameters: Record = { @@ -112,14 +119,8 @@ describe('tool intent extraction', () => { }); describe('tool intent sanitization', () => { - it('strips terminal escapes and controls and collapses whitespace', () => { - expect(sanitizeIntent('\u001B[31mcheck\n\u0007 failing\u001B[0m')).toBe('check failing'); - }); - - it('preserves visible text after an ST-terminated OSC hyperlink', () => { - expect(sanitizeIntent('\u001B]8;;https://example.com\u001B\\click\u001B]8;;\u001B\\ done')).toBe( - 'click done', - ); + it.each(SANITIZER_FIXTURES)('sanitizes intent %j', (raw, expected) => { + expect(sanitizeIntent(raw)).toBe(expected); }); it('caps the result by code points', () => { From 068f2cfbda2255edb8f97495da20815d479348e1 Mon Sep 17 00:00:00 2001 From: elkaix Date: Wed, 12 Aug 2026 02:23:43 -0400 Subject: [PATCH 6/6] fix: drop a malformed intent field instead of failing the tool call The injected intent field is required and string-typed, so a model that emitted a non-string value left it in the arguments and the call died in schema validation. The field is now removed whenever present, and an intent is produced only from a usable string. --- packages/agent-core/src/loop/tool-intent.ts | 7 ++++-- .../agent-core/test/loop/tool-intent.test.ts | 24 ++++++++++++++----- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/packages/agent-core/src/loop/tool-intent.ts b/packages/agent-core/src/loop/tool-intent.ts index b9cfe60e..c8892294 100644 --- a/packages/agent-core/src/loop/tool-intent.ts +++ b/packages/agent-core/src/loop/tool-intent.ts @@ -47,11 +47,14 @@ export function extractIntentFromArgs(args: unknown): { args: unknown; intent: string | undefined; } { - if (!isPlainRecord(args) || typeof args[INTENT_FIELD] !== 'string') { + if (!isPlainRecord(args) || !Object.hasOwn(args, INTENT_FIELD)) { return { args, intent: undefined }; } const { [INTENT_FIELD]: rawIntent, ...rest } = args; - return { args: rest, intent: sanitizeIntent(rawIntent as string) }; + return { + args: rest, + intent: typeof rawIntent === 'string' ? sanitizeIntent(rawIntent) : undefined, + }; } export function sanitizeIntent(raw: string): string | undefined { diff --git a/packages/agent-core/test/loop/tool-intent.test.ts b/packages/agent-core/test/loop/tool-intent.test.ts index fb00dae1..528f2a27 100644 --- a/packages/agent-core/test/loop/tool-intent.test.ts +++ b/packages/agent-core/test/loop/tool-intent.test.ts @@ -19,16 +19,17 @@ const SANITIZER_FIXTURES = [ function makeTool( name = 'test', - parameters: Record = { + parameters?: Record, +): ExecutableTool { + const toolParameters = parameters ?? { type: 'object', properties: { value: { type: 'string' } }, required: ['value'], - }, -): ExecutableTool { + }; return { name, description: 'Test tool.', - parameters, + parameters: toolParameters, resolveExecution: () => ({ approvalRule: name, execute: () => Promise.resolve({ output: 'ok' }), @@ -107,6 +108,13 @@ describe('tool intent extraction', () => { }); }); + it.each([1, null])('removes a non-string intent value %j', (intent) => { + expect(extractIntentFromArgs({ i: intent, value: 1 })).toEqual({ + args: { value: 1 }, + intent: undefined, + }); + }); + it('passes non-object args through', () => { const args = ['value']; expect(extractIntentFromArgs(args)).toEqual({ args, intent: undefined }); @@ -124,8 +132,12 @@ describe('tool intent sanitization', () => { }); it('caps the result by code points', () => { - const sanitized = sanitizeIntent('🙂'.repeat(INTENT_MAX_LENGTH + 1)); - expect(Array.from(sanitized ?? '')).toHaveLength(INTENT_MAX_LENGTH); + // U+1F642 SLIGHTLY SMILING FACE. Must stay a surrogate pair: this test proves + // the cap counts code points rather than UTF-16 code units. + const surrogatePair = String.fromCodePoint(0x1f642); + const sanitized = sanitizeIntent(surrogatePair.repeat(INTENT_MAX_LENGTH + 1)); + expect(typeof sanitized).toBe('string'); + expect(Array.from(sanitized as string)).toHaveLength(INTENT_MAX_LENGTH); }); it('returns undefined for an empty result', () => {