Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tool-intent-indicator.md
Original file line number Diff line number Diff line change
@@ -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`.
23 changes: 22 additions & 1 deletion apps/pythinker-code/src/tui/constant/rendering.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand All @@ -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)}…`;
}
2 changes: 1 addition & 1 deletion apps/pythinker-code/src/tui/constant/streaming.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
14 changes: 13 additions & 1 deletion apps/pythinker-code/src/tui/controllers/session-event-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -166,6 +167,7 @@ export class SessionEventHandler {
>();

resetRuntimeState(): void {
setLiveIntent(undefined);
this.backgroundTasks.clear();
this.backgroundTaskTranscriptedTerminal.clear();
this.subAgentEventHandler.resetRuntimeState();
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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',
Expand All @@ -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();
Expand All @@ -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;
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -639,6 +646,7 @@ export class SessionEventHandler {
) {
return;
}
setLiveIntent(event.intent);
const { streamingUI } = this.host;
streamingUI.flushNow();
const { turnId, step } = streamingUI.getTurnContext();
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -708,6 +718,7 @@ export class SessionEventHandler {
}

private handleToolResult(event: ToolResultEvent): void {
setLiveIntent(undefined);
const { streamingUI } = this.host;
streamingUI.flushNow();
const resultData: ToolResultBlockData = {
Expand Down Expand Up @@ -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');
Expand Down
4 changes: 3 additions & 1 deletion apps/pythinker-code/src/tui/controllers/streaming-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
150 changes: 150 additions & 0 deletions apps/pythinker-code/test/tui/tool-intent-label.test.ts
Original file line number Diff line number Diff line change
@@ -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…');
});
});
6 changes: 6 additions & 0 deletions apps/pythinker-code/test/tui/utils/event-payload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
3 changes: 2 additions & 1 deletion docs/configuration/config-files.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down
1 change: 1 addition & 0 deletions docs/configuration/env-vars.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
3 changes: 3 additions & 0 deletions packages/agent-core/src/agent/turn/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -764,6 +765,7 @@ export class TurnFlow {
log: this.agent.log,
maxSteps: maxStepsPerTurn,
maxRetryAttempts: loopControl?.maxRetriesPerStep,
toolIntentEnabled,
recordStepUsage: async (usage) => {
outputTokens += usage.output;
try {
Expand Down Expand Up @@ -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,
};
Expand Down
8 changes: 8 additions & 0 deletions packages/agent-core/src/flags/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions packages/agent-core/src/loop/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Loading
Loading