diff --git a/.changeset/tool-intent-indicator.md b/.changeset/tool-intent-indicator.md new file mode 100644 index 00000000..a88e693a --- /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: 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 a4c304d9..837e504b 100644 --- a/apps/pythinker-code/src/tui/constant/rendering.ts +++ b/apps/pythinker-code/src/tui/constant/rendering.ts @@ -132,6 +132,27 @@ 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; +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 +164,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/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/src/tui/controllers/session-event-handler.ts b/apps/pythinker-code/src/tui/controllers/session-event-handler.ts index ef66d18a..474e41a1 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(); @@ -285,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; @@ -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; } + 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']; + setLiveIntent(typeof intent === 'string' ? intent : undefined); 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..4a5535cb --- /dev/null +++ b/apps/pythinker-code/test/tui/tool-intent-label.test.ts @@ -0,0 +1,150 @@ +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'; + +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: { + 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.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', () => { + 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…'); + }); + + 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…'); + }); + + 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/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); diff --git a/docs/configuration/config-files.md b/docs/configuration/config-files.md index 07368745..0aef4e57 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 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 c56924fc..6c603e4d 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), 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/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..c8892294 --- /dev/null +++ b/packages/agent-core/src/loop/tool-intent.ts @@ -0,0 +1,68 @@ +import type { ExecutableTool } from './types'; + +export const INTENT_FIELD = 'i'; +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; + +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.filter((entry) => entry !== INTENT_FIELD)], + }, + resolveExecution: tool.resolveExecution.bind(tool), + }; + }); +} + +export function extractIntentFromArgs(args: unknown): { + args: unknown; + intent: string | undefined; +} { + if (!isPlainRecord(args) || !Object.hasOwn(args, INTENT_FIELD)) { + return { args, intent: undefined }; + } + const { [INTENT_FIELD]: rawIntent, ...rest } = args; + return { + args: rest, + intent: typeof rawIntent === 'string' ? sanitizeIntent(rawIntent) : undefined, + }; +} + +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..528f2a27 --- /dev/null +++ b/packages/agent-core/test/loop/tool-intent.test.ts @@ -0,0 +1,146 @@ +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'; + +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, +): ExecutableTool { + const toolParameters = parameters ?? { + type: 'object', + properties: { value: { type: 'string' } }, + required: ['value'], + }; + return { + name, + description: 'Test tool.', + parameters: toolParameters, + 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('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'); + try { + const tool = makeTool('omitted'); + expect(injectIntentIntoTools([tool])[0]).toBe(tool); + } finally { + omittedTools.delete('omitted'); + } + }); + + 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', + 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.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 }); + }); + + it('passes args without a string intent through', () => { + const args = { value: 1 }; + expect(extractIntentFromArgs(args)).toEqual({ args, intent: undefined }); + }); +}); + +describe('tool intent sanitization', () => { + it.each(SANITIZER_FIXTURES)('sanitizes intent %j', (raw, expected) => { + expect(sanitizeIntent(raw)).toBe(expected); + }); + + it('caps the result by code points', () => { + // 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', () => { + 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;