Skip to content

Commit adeb06b

Browse files
committed
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.
1 parent 1f45a5f commit adeb06b

18 files changed

Lines changed: 434 additions & 10 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: 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`.

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

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,26 @@ 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+
// oxlint-disable-next-line no-control-regex -- wire text must not retain terminal escape sequences.
137+
const ANSI_ESCAPE = /\u001B(?:\[[0-?]*[ -/]*[@-~]|\][^\u0007]*(?:\u0007|$))/gu;
138+
const CONTROL_CHARACTER = /\p{Cc}/gu;
139+
let liveIntent: string | undefined;
140+
141+
export function setLiveIntent(text: string | undefined): void {
142+
if (text === undefined) {
143+
liveIntent = undefined;
144+
return;
145+
}
146+
const normalized = text
147+
.replaceAll(ANSI_ESCAPE, '')
148+
.replaceAll(CONTROL_CHARACTER, ' ')
149+
.replaceAll(/\s+/gu, ' ')
150+
.trim();
151+
liveIntent =
152+
Array.from(normalized).slice(0, LIVE_INTENT_MAX_LENGTH).join('').trimEnd() || undefined;
153+
}
154+
135155
/** Rotating thinking label for the given wall-clock moment; falls back to the first label. */
136156
export function getThinkingSpinnerLabel(nowMs: number = Date.now()): string {
137157
const index =
@@ -143,5 +163,5 @@ export function getThinkingSpinnerLabel(nowMs: number = Date.now()): string {
143163

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

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

Lines changed: 12 additions & 0 deletions
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();
@@ -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+
if (event.intent !== undefined) 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+
if (typeof intent === 'string') setLiveIntent(intent);
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: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
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+
function makeStartupInput(): PythinkerTUIStartupInput {
12+
return {
13+
cliOptions: {
14+
session: undefined,
15+
continue: false,
16+
rewindFiles: undefined,
17+
yolo: false,
18+
auto: false,
19+
plan: false,
20+
model: undefined,
21+
outputFormat: undefined,
22+
prompt: undefined,
23+
skillsDirs: [],
24+
},
25+
tuiConfig: {
26+
theme: 'dark',
27+
layout: 'inline',
28+
copyFullResponse: false,
29+
editorCommand: null,
30+
notifications: { enabled: true, condition: 'unfocused' },
31+
upgrade: { autoInstall: true },
32+
statusLine: DEFAULT_STATUS_LINE_CONFIG,
33+
},
34+
version: '0.0.0-test',
35+
workDir: '/tmp/tool-intent-test',
36+
};
37+
}
38+
39+
afterEach(() => {
40+
setLiveIntent(undefined);
41+
});
42+
43+
describe('tool intent thinking label', () => {
44+
it('uses the live intent and restores the rotating label when cleared', () => {
45+
setLiveIntent('check failing test');
46+
expect(formatThinkingSpinnerLabel(0)).toBe('check failing test…');
47+
48+
setLiveIntent(undefined);
49+
expect(formatThinkingSpinnerLabel(0)).toBe('thinking…');
50+
});
51+
52+
it('removes control characters before display', () => {
53+
setLiveIntent('\u001B[31mcheck\n\u0007 failing test\u001B[0m');
54+
expect(formatThinkingSpinnerLabel(0)).toBe('check failing test…');
55+
});
56+
57+
it('sets intent from a tool delta and clears it on the result', () => {
58+
const driver = new PythinkerTUI({} as never, makeStartupInput());
59+
const dispatch = (event: Event): void =>
60+
driver.sessionEventHandler.handleEvent(event, vi.fn());
61+
62+
dispatch({
63+
type: 'tool.call.delta',
64+
agentId: 'main',
65+
sessionId: 'session-1',
66+
turnId: 1,
67+
toolCallId: 'call-1',
68+
name: 'echo',
69+
argumentsPart: '{"i":"check failing test","text":"hello"}',
70+
});
71+
expect(formatThinkingSpinnerLabel(0)).toBe('check failing test…');
72+
73+
dispatch({
74+
type: 'tool.result',
75+
agentId: 'main',
76+
sessionId: 'session-1',
77+
turnId: 1,
78+
toolCallId: 'call-1',
79+
output: 'hello',
80+
});
81+
expect(formatThinkingSpinnerLabel(0)).toBe('thinking…');
82+
});
83+
});

docs/configuration/config-files.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,11 +187,12 @@ You can also switch models temporarily without touching the config file — by s
187187

188188
## `experimental`
189189

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

192192
| Field | Type | Default | Description |
193193
| --- | --- | --- | --- |
194194
| `micro_compaction` | `boolean` | `true` | Trim older large tool results from context while preserving recent conversation |
195+
| `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 |
195196

196197
## `services`
197198

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), 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 |
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
};

packages/agent-core/src/flags/registry.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,14 @@ export const FLAG_DEFINITIONS = [
2020
default: true,
2121
surface: 'core',
2222
},
23+
{
24+
id: 'tool_intent',
25+
title: 'Tool intent indicator',
26+
description: 'Ask the model to state a concise intent with each tool call and show it in the working indicator.',
27+
env: 'PYTHINKER_CODE_EXPERIMENTAL_TOOL_INTENT',
28+
default: true,
29+
surface: 'core',
30+
},
2331
{
2432
id: 'vim_mode',
2533
title: 'Vim mode',

packages/agent-core/src/loop/events.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ export interface LoopToolCallEvent {
6161
readonly toolCallId: string;
6262
readonly name: string;
6363
readonly args: unknown;
64+
readonly intent?: string | undefined;
6465
readonly description?: string | undefined;
6566
readonly display?: ToolInputDisplay | undefined;
6667
}

0 commit comments

Comments
 (0)