Skip to content

Commit 99c427c

Browse files
authored
feat: show model-written tool intent in the working indicator (#57)
## Related Issue No linked issue — directly requested feature; problem explained below. ## Problem While the agent works, the spinner shows a random rotating verb ("pythinking…", "marinating…") that carries no information about what the agent is actually doing. Users watching a long turn cannot tell whether the agent is reading, editing, or running tests without expanding tool cards. ## What changed Each tool call now carries a short, model-written intent that the working indicator shows live. - Tool schemas sent to providers gain an injected required first property `i` ("concise intent"). Injection happens on request-only schema clones — registered tools and their validation schemas are never mutated. Tools with an exact schema contract (`StructuredOutput`), an existing `i` property, or a non-object schema root are skipped. - The runtime strips `i` after JSON parse and before validation, so hooks, permission prompts, execution, and the persisted transcript all see clean arguments. The sanitized intent (control characters stripped, 120-char cap) rides the `tool.call.started` event as a new optional `intent` field. - The TUI streams the intent into the spinner label from partial tool-call arguments (the field is first in the schema, so it arrives at the head of the stream on most wires), falls back to the `tool.call.started` intent, and clears on every turn/step/result boundary. Without an intent, the rotating labels behave exactly as before. - Gated by the `tool_intent` experimental flag, default on; disable with `PYTHINKER_CODE_EXPERIMENTAL_TOOL_INTENT=0` or `[experimental] tool_intent = false`. ## Checklist - [x] I have read the [CONTRIBUTING](https://github.com/Pythoughts-labs/pythinker-code/blob/main/CONTRIBUTING.md) document. - [x] I have linked a related issue, or explained the problem above. - [x] I have added tests that prove my feature works. - [x] Ran `gen-changesets` skill, or this PR needs no changeset. - [x] Ran `gen-docs` skill, or this PR needs no doc update. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - The working indicator now shows live descriptions of ongoing tool actions. - Tool-call events can include optional intent descriptions. - Intent display is enabled by default and can be disabled through experimental configuration or an environment variable. - Intent text is sanitized, normalized, and limited in length for clear, safe display. - **Documentation** - Added configuration guidance for controlling tool-intent display. - **Tests** - Added coverage for intent display, lifecycle behavior, sanitization, streaming, and tool-call handling. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 6999b68 commit 99c427c

20 files changed

Lines changed: 550 additions & 12 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pythoughts/pythinker-code": minor
3+
---
4+
5+
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`.

apps/pythinker-code/src/tui/constant/rendering.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,27 @@ export const THINKING_SPINNER_LABELS = [
132132

133133
export const THINKING_SPINNER_LABEL_INTERVAL_MS = 12_000;
134134

135+
const LIVE_INTENT_MAX_LENGTH = 120;
136+
// Keep in sync with packages/agent-core/src/loop/tool-intent.ts.
137+
// oxlint-disable-next-line no-control-regex -- wire text must not retain terminal escape sequences.
138+
const ANSI_ESCAPE = /\u001B(?:\[[0-?]*[ -/]*[@-~]|\][^\u0007\u001B]*(?:\u0007|\u001B\\|$))/gu;
139+
const CONTROL_CHARACTER = /\p{Cc}/gu;
140+
let liveIntent: string | undefined;
141+
142+
export function setLiveIntent(text: string | undefined): void {
143+
if (text === undefined) {
144+
liveIntent = undefined;
145+
return;
146+
}
147+
const normalized = text
148+
.replaceAll(ANSI_ESCAPE, '')
149+
.replaceAll(CONTROL_CHARACTER, ' ')
150+
.replaceAll(/\s+/gu, ' ')
151+
.trim();
152+
liveIntent =
153+
Array.from(normalized).slice(0, LIVE_INTENT_MAX_LENGTH).join('').trimEnd() || undefined;
154+
}
155+
135156
/** Rotating thinking label for the given wall-clock moment; falls back to the first label. */
136157
export function getThinkingSpinnerLabel(nowMs: number = Date.now()): string {
137158
const index =
@@ -143,5 +164,5 @@ export function getThinkingSpinnerLabel(nowMs: number = Date.now()): string {
143164

144165
/** Thinking label plus an ellipsis, for the thinking block header. */
145166
export function formatThinkingSpinnerLabel(nowMs: number = Date.now()): string {
146-
return `${getThinkingSpinnerLabel(nowMs)}…`;
167+
return `${liveIntent ?? getThinkingSpinnerLabel(nowMs)}…`;
147168
}

apps/pythinker-code/src/tui/constant/streaming.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Extracts useful string fields from partially streamed JSON tool args.
22
// This is intentionally a preview parser, not a full JSON parser.
33
export const STREAMING_ARGS_FIELD_RE =
4-
/"(path|file_path|command|pattern|query|url|description|title|name)"\s*:\s*"((?:\\.|[^"\\])*)"/g;
4+
/"(i|path|file_path|command|pattern|query|url|description|title|name)"\s*:\s*"((?:\\.|[^"\\])*)"/g;
55

66
// Bounds live tool-argument previews; final tool.call payloads remain complete.
77
export const STREAMING_ARGS_PREVIEW_MAX_CHARS = 64 * 1024;

apps/pythinker-code/src/tui/controllers/session-event-handler.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import {
4444
OAUTH_LOGIN_REQUIRED_STARTUP_NOTICE,
4545
} from '../constant/pythinker-tui';
4646
import { FAILURE_MARK, STATUS_BULLET, SUCCESS_MARK } from '../constant/symbols';
47+
import { setLiveIntent } from '../constant/rendering';
4748
import { buildGoalCompletionMessage } from '../utils/goal-completion';
4849
import {
4950
argsRecord,
@@ -166,6 +167,7 @@ export class SessionEventHandler {
166167
>();
167168

168169
resetRuntimeState(): void {
170+
setLiveIntent(undefined);
169171
this.backgroundTasks.clear();
170172
this.backgroundTaskTranscriptedTerminal.clear();
171173
this.subAgentEventHandler.resetRuntimeState();
@@ -285,7 +287,7 @@ export class SessionEventHandler {
285287
case 'turn.step.started': this.handleStepBegin(event); break;
286288
case 'turn.step.interrupted': this.handleStepInterrupted(event); break;
287289
case 'turn.step.completed': this.handleStepCompleted(event); break;
288-
case 'turn.step.retrying': break;
290+
case 'turn.step.retrying': setLiveIntent(undefined); break;
289291
case 'tool.progress': this.handleToolProgress(event); break;
290292
case 'assistant.delta': this.handleAssistantDelta(event); break;
291293
case 'hook.result': this.handleHookResult(event); break;
@@ -361,6 +363,7 @@ export class SessionEventHandler {
361363
// ---------------------------------------------------------------------------
362364

363365
private handleTurnBegin(_event: TurnStartedEvent): void {
366+
setLiveIntent(undefined);
364367
void _event;
365368
this.currentTurnHasAssistantText = false;
366369
// Throughput belongs to the finished turn; clear it so a stale t/s rate
@@ -402,6 +405,7 @@ export class SessionEventHandler {
402405
}
403406

404407
private handleTurnEnd(event: TurnEndedEvent, sendQueued: (item: QueuedMessage) => void): void {
408+
setLiveIntent(undefined);
405409
this.host.streamingUI.flushNow();
406410
this.host.dispatchFooter({
407411
type: 'status.updated',
@@ -423,6 +427,7 @@ export class SessionEventHandler {
423427
}
424428

425429
private handleStepBegin(event: TurnStepStartedEvent): void {
430+
setLiveIntent(undefined);
426431
this.host.streamingUI.flushNow();
427432
this.host.streamingUI.setStep(event.step);
428433
this.host.streamingUI.resetToolUi();
@@ -439,6 +444,7 @@ export class SessionEventHandler {
439444
}
440445

441446
private handleStepCompleted(event: TurnStepCompletedEvent): void {
447+
setLiveIntent(undefined);
442448
this.host.streamingUI.flushNow();
443449
this.maybeShowDebugTiming(event);
444450
if (event.finishReason !== 'max_tokens') return;
@@ -555,6 +561,7 @@ export class SessionEventHandler {
555561
}
556562

557563
private handleStepInterrupted(event: TurnStepInterruptedEvent): void {
564+
setLiveIntent(undefined);
558565
this.host.streamingUI.flushNow();
559566
this.host.streamingUI.resetToolUi();
560567
this.host.streamingUI.finalizeLiveTextBuffers('idle');
@@ -639,6 +646,7 @@ export class SessionEventHandler {
639646
) {
640647
return;
641648
}
649+
setLiveIntent(event.intent);
642650
const { streamingUI } = this.host;
643651
streamingUI.flushNow();
644652
const { turnId, step } = streamingUI.getTurnContext();
@@ -673,6 +681,8 @@ export class SessionEventHandler {
673681
const { state, streamingUI } = this.host;
674682
streamingUI.accumulateToolCallDelta(event.toolCallId, event.name, event.argumentsPart);
675683
const preview = streamingUI.getStreamingToolCallPreview(event.toolCallId);
684+
const intent = preview?.args['i'];
685+
setLiveIntent(typeof intent === 'string' ? intent : undefined);
676686
if (
677687
preview !== undefined &&
678688
preview.name === 'DynamicWorkflow'
@@ -708,6 +718,7 @@ export class SessionEventHandler {
708718
}
709719

710720
private handleToolResult(event: ToolResultEvent): void {
721+
setLiveIntent(undefined);
711722
const { streamingUI } = this.host;
712723
streamingUI.flushNow();
713724
const resultData: ToolResultBlockData = {
@@ -1003,6 +1014,7 @@ export class SessionEventHandler {
10031014
}
10041015

10051016
private handleSessionError(event: ErrorEvent): void {
1017+
setLiveIntent(undefined);
10061018
this.host.streamingUI.flushNow();
10071019
this.host.streamingUI.resetToolUi();
10081020
this.host.streamingUI.finalizeLiveTextBuffers('idle');

apps/pythinker-code/src/tui/controllers/streaming-ui.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -737,10 +737,12 @@ export class StreamingUIController {
737737
private flushToolCallPreview(id: string): void {
738738
const streaming = this._streamingToolCallArguments.get(id);
739739
if (streaming === undefined) return;
740+
const args = parseStreamingArgs(streaming.argumentsText);
741+
if (typeof args['i'] === 'string') delete args['i'];
740742
const toolCall: ToolCallBlockData = {
741743
id,
742744
name: streaming.name ?? this._activeToolCalls.get(id)?.name ?? 'Tool',
743-
args: parseStreamingArgs(streaming.argumentsText),
745+
args,
744746
streamingArguments: streaming.argumentsText,
745747
streamingStartedAtMs: streaming.startedAtMs,
746748
step: this._currentStep,
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import type { Event } from '@pythoughts/pythinker-code-sdk';
2+
import { afterEach, describe, expect, it, vi } from 'vitest';
3+
4+
import { DEFAULT_STATUS_LINE_CONFIG } from '#/tui/config';
5+
import {
6+
formatThinkingSpinnerLabel,
7+
setLiveIntent,
8+
} from '#/tui/constant/rendering';
9+
import { PythinkerTUI, type PythinkerTUIStartupInput } from '#/tui/pythinker-tui';
10+
11+
const SANITIZER_FIXTURES = [
12+
['\u001B[31mred\u001B[0m', 'red'],
13+
['\u001B]0;title\u0007visible', 'visible'],
14+
['\u001B]0;title\u001B\\visible', 'visible'],
15+
['check\n\u0007test', 'check test'],
16+
] as const;
17+
18+
function makeStartupInput(): PythinkerTUIStartupInput {
19+
return {
20+
cliOptions: {
21+
session: undefined,
22+
continue: false,
23+
rewindFiles: undefined,
24+
yolo: false,
25+
auto: false,
26+
plan: false,
27+
model: undefined,
28+
outputFormat: undefined,
29+
prompt: undefined,
30+
skillsDirs: [],
31+
},
32+
tuiConfig: {
33+
theme: 'dark',
34+
layout: 'inline',
35+
copyFullResponse: false,
36+
editorCommand: null,
37+
notifications: { enabled: true, condition: 'unfocused' },
38+
upgrade: { autoInstall: true },
39+
statusLine: DEFAULT_STATUS_LINE_CONFIG,
40+
},
41+
version: '0.0.0-test',
42+
workDir: '/tmp/tool-intent-test',
43+
};
44+
}
45+
46+
afterEach(() => {
47+
setLiveIntent(undefined);
48+
});
49+
50+
describe('tool intent thinking label', () => {
51+
it('uses the live intent and restores the rotating label when cleared', () => {
52+
setLiveIntent('check failing test');
53+
expect(formatThinkingSpinnerLabel(0)).toBe('check failing test…');
54+
55+
setLiveIntent(undefined);
56+
expect(formatThinkingSpinnerLabel(0)).toBe('thinking…');
57+
});
58+
59+
it.each(SANITIZER_FIXTURES)('sanitizes intent %j', (raw, expected) => {
60+
setLiveIntent(raw);
61+
expect(formatThinkingSpinnerLabel(0)).toBe(`${expected}…`);
62+
});
63+
64+
it('sets intent from a tool delta and clears it on the result', () => {
65+
const driver = new PythinkerTUI({} as never, makeStartupInput());
66+
const dispatch = (event: Event): void =>
67+
driver.sessionEventHandler.handleEvent(event, vi.fn());
68+
69+
dispatch({
70+
type: 'tool.call.delta',
71+
agentId: 'main',
72+
sessionId: 'session-1',
73+
turnId: 1,
74+
toolCallId: 'call-1',
75+
name: 'echo',
76+
argumentsPart: '{"i":"check failing test","text":"hello"}',
77+
});
78+
expect(formatThinkingSpinnerLabel(0)).toBe('check failing test…');
79+
80+
dispatch({
81+
type: 'tool.result',
82+
agentId: 'main',
83+
sessionId: 'session-1',
84+
turnId: 1,
85+
toolCallId: 'call-1',
86+
output: 'hello',
87+
});
88+
expect(formatThinkingSpinnerLabel(0)).toBe('thinking…');
89+
});
90+
91+
it('clears a stale intent when the next tool call has no intent', () => {
92+
const driver = new PythinkerTUI({} as never, makeStartupInput());
93+
const dispatch = (event: Event): void =>
94+
driver.sessionEventHandler.handleEvent(event, vi.fn());
95+
96+
dispatch({
97+
type: 'tool.call.started',
98+
agentId: 'main',
99+
sessionId: 'session-1',
100+
turnId: 1,
101+
toolCallId: 'call-1',
102+
name: 'echo',
103+
args: {},
104+
intent: 'check failing test',
105+
});
106+
dispatch({
107+
type: 'tool.call.started',
108+
agentId: 'main',
109+
sessionId: 'session-1',
110+
turnId: 1,
111+
toolCallId: 'call-2',
112+
name: 'StructuredOutput',
113+
args: {},
114+
});
115+
116+
expect(formatThinkingSpinnerLabel(0)).toBe('thinking…');
117+
});
118+
119+
it('clears the live intent when a step retries', () => {
120+
const driver = new PythinkerTUI({} as never, makeStartupInput());
121+
const dispatch = (event: Event): void =>
122+
driver.sessionEventHandler.handleEvent(event, vi.fn());
123+
124+
dispatch({
125+
type: 'tool.call.started',
126+
agentId: 'main',
127+
sessionId: 'session-1',
128+
turnId: 1,
129+
toolCallId: 'call-1',
130+
name: 'echo',
131+
args: {},
132+
intent: 'check failing test',
133+
});
134+
dispatch({
135+
type: 'turn.step.retrying',
136+
agentId: 'main',
137+
sessionId: 'session-1',
138+
turnId: 1,
139+
step: 1,
140+
failedAttempt: 1,
141+
nextAttempt: 2,
142+
maxAttempts: 3,
143+
delayMs: 100,
144+
errorName: 'Error',
145+
errorMessage: 'retry',
146+
});
147+
148+
expect(formatThinkingSpinnerLabel(0)).toBe('thinking…');
149+
});
150+
});

apps/pythinker-code/test/tui/utils/event-payload.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@ describe('streaming tool argument payload helpers', () => {
1818
});
1919
});
2020

21+
it('parses intent from partial streaming arguments', () => {
22+
expect(parseStreamingArgs('{"i":"scan configs","path":"/tmp/x')).toMatchObject({
23+
i: 'scan configs',
24+
});
25+
});
26+
2127
it('caps accumulated streaming preview text', () => {
2228
const current = 'a'.repeat(STREAMING_ARGS_PREVIEW_MAX_CHARS - 2);
2329

docs/configuration/config-files.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -229,11 +229,12 @@ advisor = "reviewer-model"
229229

230230
## `experimental`
231231

232-
`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.
232+
`experimental` stores persistent overrides for experimental-feature flags.
233233

234234
| Field | Type | Default | Description |
235235
| --- | --- | --- | --- |
236236
| `micro_compaction` | `boolean` | `true` | Trim older large tool results from context while preserving recent conversation |
237+
| `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 |
237238

238239
## `services`
239240

docs/configuration/env-vars.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ Switches that control the behavior of subsystems such as telemetry, background t
135135
| `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` |
136136
| `PYTHINKER_CODE_EXPERIMENTAL_FLAG` | Enable all registered experimental features for this process; `micro_compaction` is already enabled by default | `1`, `true`, `yes`, `on` |
137137
| `PYTHINKER_CODE_EXPERIMENTAL_MICRO_COMPACTION` | Override [`[experimental].micro_compaction`](./config-files.md#experimental) for this process | Truthy or falsy |
138+
| `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 |
138139
| `PYTHINKER_SHELL_PATH` | Override the Git Bash path on Windows (used when auto-detection fails) | Absolute path |
139140
| `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 |
140141
| `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` |

packages/agent-core/src/agent/turn/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -746,6 +746,7 @@ export class TurnFlow {
746746
this.agent.config.maxStepsPerTurn ?? loopControl?.maxStepsPerTurn;
747747
let stopForGoalBudget = false;
748748
try {
749+
const toolIntentEnabled = this.agent.experimentalFlags.enabled('tool_intent');
749750
const result = await runTurn({
750751
turnId: String(turnId),
751752
signal,
@@ -764,6 +765,7 @@ export class TurnFlow {
764765
log: this.agent.log,
765766
maxSteps: maxStepsPerTurn,
766767
maxRetryAttempts: loopControl?.maxRetriesPerStep,
768+
toolIntentEnabled,
767769
recordStepUsage: async (usage) => {
768770
outputTokens += usage.output;
769771
try {
@@ -1232,6 +1234,7 @@ function mapLoopEvent(event: LoopEvent, turnId: number): AgentEvent | undefined
12321234
toolCallId: event.toolCallId,
12331235
name: event.name,
12341236
args: event.args,
1237+
intent: event.intent,
12351238
description: event.description,
12361239
display: event.display,
12371240
};

0 commit comments

Comments
 (0)