Skip to content

feat: show model-written tool intent in the working indicator - #57

Merged
elkaix merged 6 commits into
mainfrom
feat/tool-intent
Aug 12, 2026
Merged

feat: show model-written tool intent in the working indicator#57
elkaix merged 6 commits into
mainfrom
feat/tool-intent

Conversation

@elkaix

@elkaix elkaix commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

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

  • I have read the CONTRIBUTING document.
  • I have linked a related issue, or explained the problem above.
  • I have added tests that prove my feature works.
  • Ran gen-changesets skill, or this PR needs no changeset.
  • Ran gen-docs skill, or this PR needs no doc update.

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.

@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: ab987d5e-8c2d-4901-9820-abc1f8692461

📥 Commits

Reviewing files that changed from the base of the PR and between e5ff2c3 and 068f2cf.

📒 Files selected for processing (2)
  • packages/agent-core/src/loop/tool-intent.ts
  • packages/agent-core/test/loop/tool-intent.test.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • packages/agent-core/test/loop/tool-intent.test.ts
  • packages/agent-core/src/loop/tool-intent.ts

📝 Walkthrough

Walkthrough

The change adds optional tool-intent injection to the agent loop, propagates intent through protocol events, and displays sanitized live intent in the TUI. An experimental flag and environment variable control the feature.

Changes

Tool intent flow

Layer / File(s) Summary
Intent contracts and transformation
packages/agent-core/src/loop/tool-intent.ts, packages/agent-core/src/loop/events.ts, packages/protocol/src/events.ts, packages/protocol/src/__tests__/events.test.ts, packages/agent-core/test/loop/tool-intent.test.ts
Eligible tool schemas receive a required intent field. Intent values are extracted and sanitized. Tool-call events carry the extracted intent.
Loop injection and event propagation
packages/agent-core/src/loop/run-turn.ts, packages/agent-core/src/loop/turn-step.ts, packages/agent-core/src/loop/tool-call.ts, packages/agent-core/src/agent/turn/index.ts, packages/agent-core/test/loop/tool-call.e2e.test.ts
The loop conditionally injects intent, removes it before validation and execution, and preserves it in emitted event metadata.
Configuration and live TUI lifecycle
packages/agent-core/src/flags/registry.ts, apps/pythinker-code/src/tui/constant/*, apps/pythinker-code/src/tui/controllers/*, apps/pythinker-code/test/tui/*, docs/configuration/*, .changeset/tool-intent-indicator.md
The experimental flag and environment variable control the feature. The TUI renders live intent and clears it across session lifecycle events.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant AgentTurn
  participant RunTurn
  participant ExecuteLoopStep
  participant ToolCallPreflight
  participant TUI
  AgentTurn->>RunTurn: pass toolIntentEnabled
  RunTurn->>ExecuteLoopStep: forward toolIntentEnabled
  ExecuteLoopStep->>ToolCallPreflight: inject and preflight tool intent
  ToolCallPreflight->>TUI: publish tool-call intent event
  TUI->>TUI: render or clear live intent
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 12.50% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title uses the feat prefix, imperative mood, stays within 72 characters, and accurately describes the main change.
Description check ✅ Passed The description includes the required issue, problem, changes, and checklist sections, with complete and relevant information.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Comment @coderabbitai help to get the list of available commands.

@pkg-pr-new

pkg-pr-new Bot commented Aug 11, 2026

Copy link
Copy Markdown
pnpm dlx https://pkg.pr.new/@pythoughts/pythinker-code@068f2cf
npx https://pkg.pr.new/@pythoughts/pythinker-code@068f2cf

commit: 068f2cf

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/agent-core/src/loop/tool-call.ts (1)

267-289: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Strip intent from hook-visible ToolCall objects.

Preflight replaces call.args, but runPrepareToolExecutionHook still passes raw call.toolCall.arguments and raw step.toolCalls to hooks. An enabled call can therefore expose the original unbounded and unsanitized i value to hook code.

  • packages/agent-core/src/loop/tool-call.ts#L267-L289: serialize the extracted arguments into the preflighted ToolCall, and construct the batch hook list from preflighted calls.
  • packages/agent-core/test/loop/tool-call.e2e.test.ts#L122-L146: assert that context.toolCall.arguments and context.toolCalls provided to hooks do not contain i.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/agent-core/src/loop/tool-call.ts` around lines 267 - 289, Update the
preflight result construction in tool-call.ts so runnable and rejected ToolCall
objects serialize extracted.args into toolCall.arguments, removing the injected
i field before hooks observe them; ensure batch hook inputs use these
preflighted calls rather than raw step.toolCalls. In
packages/agent-core/src/loop/tool-call.ts lines 267-289, update the relevant
result construction and hook-list flow; in
packages/agent-core/test/loop/tool-call.e2e.test.ts lines 122-146, add
assertions that both context.toolCall.arguments and context.toolCalls omit i.
🧹 Nitpick comments (3)
packages/agent-core/test/loop/tool-intent.test.ts (2)

15-19: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Create the default schema for each helper call.

Oxlint reports unicorn(no-object-as-default-parameter) here. Return a fresh default schema from a helper so a future fixture mutation cannot leak into another test.

As per coding guidelines, use oxlint for linting.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/agent-core/test/loop/tool-intent.test.ts` around lines 15 - 19,
Replace the mutable default object parameter near the helper call with a helper
that creates and returns a fresh schema object on each invocation. Update the
relevant helper signature to use that factory, preserving the existing schema
contents and run oxlint to verify the lint issue is resolved.

Sources: Coding guidelines, Linters/SAST tools


107-110: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Remove the conditional from this Vitest assertion.

Oxlint reports vitest(no-conditional-in-test) for sanitized ?? ''. Compare directly with '🙂'.repeat(INTENT_MAX_LENGTH) to verify both the cap and the exact result without a conditional.

As per coding guidelines, use oxlint for linting.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/agent-core/test/loop/tool-intent.test.ts` around lines 107 - 110,
Update the test case “caps the result by code points” to assert sanitized
directly against '🙂'.repeat(INTENT_MAX_LENGTH), removing the sanitized ?? ''
conditional while still verifying the capped length and exact result. Use oxlint
to confirm the test no longer triggers vitest(no-conditional-in-test).

Sources: Coding guidelines, Linters/SAST tools

packages/agent-core/test/loop/tool-call.e2e.test.ts (1)

71-104: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert the provider schema in this integration helper.

The fake provider always returns an i argument. The enabled test can pass if executeLoopStep stops sending the injected schema to the provider. Return llm from this helper and assert that its first call contains an eligible tool schema with required i.

As per path instructions, packages/**/*.ts requires new behavior to have Vitest coverage.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/agent-core/test/loop/tool-call.e2e.test.ts` around lines 71 - 104,
Update runToolIntentTurn to return the FakeLLM instance alongside strict, sink,
and context. Add Vitest coverage in the enabled tool-intent test that inspects
the first LLM call and verifies an eligible tool schema is present with i listed
as required, ensuring the injected schema reaches the provider.

Source: Path instructions

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/pythinker-code/src/tui/controllers/session-event-handler.ts`:
- Line 649: Update
apps/pythinker-code/src/tui/controllers/session-event-handler.ts at lines 649
and 684-685: always call setLiveIntent with event.intent, and pass undefined
when preview?.args['i'] is not a string so prior labels are cleared. Add a
regression test covering an intent-bearing tool event followed by one without
intent.

In `@docs/configuration/config-files.md`:
- Line 195: Update the tool-intent documentation to describe the feature as
applying only to eligible tool calls: change the wording in
docs/configuration/config-files.md at lines 195-195,
docs/configuration/env-vars.md at lines 138-138, and
.changeset/tool-intent-indicator.md at lines 5-5, preserving the existing
descriptions while scoping each statement to eligibility.

In `@packages/agent-core/src/loop/tool-intent.ts`:
- Line 9: Update the ANSI_ESCAPE OSC branch to terminate at either BEL or the
standard ST sequence (ESC followed by backslash), so it does not consume
following visible text. Add coverage for an ST-terminated OSC hyperlink followed
by text and verify the text is preserved.
- Around line 19-39: The required-field merge in injectIntentIntoTools can emit
duplicate INTENT_FIELD entries when the schema already requires it without
defining the property. Treat INTENT_FIELD as already present when it appears in
schema.required, or deduplicate the merged required array before returning the
injected schema; preserve existing required fields and add coverage for a
required INTENT_FIELD missing from properties.

---

Outside diff comments:
In `@packages/agent-core/src/loop/tool-call.ts`:
- Around line 267-289: Update the preflight result construction in tool-call.ts
so runnable and rejected ToolCall objects serialize extracted.args into
toolCall.arguments, removing the injected i field before hooks observe them;
ensure batch hook inputs use these preflighted calls rather than raw
step.toolCalls. In packages/agent-core/src/loop/tool-call.ts lines 267-289,
update the relevant result construction and hook-list flow; in
packages/agent-core/test/loop/tool-call.e2e.test.ts lines 122-146, add
assertions that both context.toolCall.arguments and context.toolCalls omit i.

---

Nitpick comments:
In `@packages/agent-core/test/loop/tool-call.e2e.test.ts`:
- Around line 71-104: Update runToolIntentTurn to return the FakeLLM instance
alongside strict, sink, and context. Add Vitest coverage in the enabled
tool-intent test that inspects the first LLM call and verifies an eligible tool
schema is present with i listed as required, ensuring the injected schema
reaches the provider.

In `@packages/agent-core/test/loop/tool-intent.test.ts`:
- Around line 15-19: Replace the mutable default object parameter near the
helper call with a helper that creates and returns a fresh schema object on each
invocation. Update the relevant helper signature to use that factory, preserving
the existing schema contents and run oxlint to verify the lint issue is
resolved.
- Around line 107-110: Update the test case “caps the result by code points” to
assert sanitized directly against '🙂'.repeat(INTENT_MAX_LENGTH), removing the
sanitized ?? '' conditional while still verifying the capped length and exact
result. Use oxlint to confirm the test no longer triggers
vitest(no-conditional-in-test).
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: fce28038-8932-482a-bf7a-4dff91378d93

📥 Commits

Reviewing files that changed from the base of the PR and between 45887dc and 582ff5d.

📒 Files selected for processing (18)
  • .changeset/tool-intent-indicator.md
  • apps/pythinker-code/src/tui/constant/rendering.ts
  • apps/pythinker-code/src/tui/controllers/session-event-handler.ts
  • apps/pythinker-code/src/tui/controllers/streaming-ui.ts
  • apps/pythinker-code/test/tui/tool-intent-label.test.ts
  • docs/configuration/config-files.md
  • docs/configuration/env-vars.md
  • packages/agent-core/src/agent/turn/index.ts
  • packages/agent-core/src/flags/registry.ts
  • packages/agent-core/src/loop/events.ts
  • packages/agent-core/src/loop/run-turn.ts
  • packages/agent-core/src/loop/tool-call.ts
  • packages/agent-core/src/loop/tool-intent.ts
  • packages/agent-core/src/loop/turn-step.ts
  • packages/agent-core/test/loop/tool-call.e2e.test.ts
  • packages/agent-core/test/loop/tool-intent.test.ts
  • packages/protocol/src/__tests__/events.test.ts
  • packages/protocol/src/events.ts

Comment thread apps/pythinker-code/src/tui/controllers/session-event-handler.ts Outdated
Comment thread docs/configuration/config-files.md Outdated
Comment thread packages/agent-core/src/loop/tool-intent.ts Outdated
Comment thread packages/agent-core/src/loop/tool-intent.ts
@elkaix
elkaix changed the base branch from main to feat/advisor-runtime August 12, 2026 00:57
@elkaix
elkaix force-pushed the feat/tool-intent branch 2 times, most recently from 34230c4 to e8fa4d0 Compare August 12, 2026 04:13
@elkaix
elkaix force-pushed the feat/tool-intent branch 4 times, most recently from 57fd00d to 388d3ee Compare August 12, 2026 05:59
Base automatically changed from feat/advisor-runtime to main August 12, 2026 06:07
elkaix added 5 commits August 12, 2026 02:07
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.
StructuredOutput is mechanical and its consumers treat the advertised
schema as an exact contract.
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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (1)
apps/pythinker-code/src/tui/constant/streaming.ts (1)

4-4: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add the u flag to the regular expression.

Oxlint reports require-unicode-regexp for this changed expression. Change the flags from g to gu.

Proposed fix
 export const STREAMING_ARGS_FIELD_RE =
-  /"(i|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*"((?:\\.|[^"\\])*)"/gu;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/pythinker-code/src/tui/constant/streaming.ts` at line 4, Update the
regular expression in the streaming constant to use Unicode-aware matching by
changing its flags from g to gu, resolving the require-unicode-regexp lint
violation while preserving the existing global matching behavior.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/agent-core/test/loop/tool-intent.test.ts`:
- Around line 115-118: Extend the test for extractIntentFromArgs with malformed
i-field input such as an object containing numeric i and value fields, and
assert that the returned args omit i while intent remains undefined. Preserve
the existing no-i coverage and verify removal occurs before validation for
non-string i values.
- Around line 22-26: Update makeTool so parameters is optional rather than an
object-literal default parameter, then create the existing default schema inside
the function body when no parameters are provided. Preserve the current schema
and behavior for explicitly supplied parameters, and verify the change with
oxlint.
- Line 128: Update the test assertion around sanitized to first assert that
sanitized is defined, then measure its code-point length directly with
Array.from(sanitized). Remove the nullish fallback while preserving the
INTENT_MAX_LENGTH expectation.

---

Nitpick comments:
In `@apps/pythinker-code/src/tui/constant/streaming.ts`:
- Line 4: Update the regular expression in the streaming constant to use
Unicode-aware matching by changing its flags from g to gu, resolving the
require-unicode-regexp lint violation while preserving the existing global
matching behavior.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 1e3f4ece-ab5c-4a33-ae1e-022148000fa2

📥 Commits

Reviewing files that changed from the base of the PR and between 582ff5d and e5ff2c3.

📒 Files selected for processing (10)
  • .changeset/tool-intent-indicator.md
  • apps/pythinker-code/src/tui/constant/rendering.ts
  • apps/pythinker-code/src/tui/constant/streaming.ts
  • apps/pythinker-code/src/tui/controllers/session-event-handler.ts
  • apps/pythinker-code/test/tui/tool-intent-label.test.ts
  • apps/pythinker-code/test/tui/utils/event-payload.test.ts
  • docs/configuration/config-files.md
  • docs/configuration/env-vars.md
  • packages/agent-core/src/loop/tool-intent.ts
  • packages/agent-core/test/loop/tool-intent.test.ts
🚧 Files skipped from review as they are similar to previous changes (6)
  • apps/pythinker-code/test/tui/tool-intent-label.test.ts
  • docs/configuration/env-vars.md
  • packages/agent-core/src/loop/tool-intent.ts
  • apps/pythinker-code/src/tui/constant/rendering.ts
  • apps/pythinker-code/src/tui/controllers/session-event-handler.ts
  • .changeset/tool-intent-indicator.md

Comment thread packages/agent-core/test/loop/tool-intent.test.ts Outdated
Comment thread packages/agent-core/test/loop/tool-intent.test.ts
Comment thread packages/agent-core/test/loop/tool-intent.test.ts Outdated
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.
@elkaix
elkaix merged commit 99c427c into main Aug 12, 2026
11 checks passed
@elkaix
elkaix deleted the feat/tool-intent branch August 12, 2026 06:30
elkaix pushed a commit that referenced this pull request Aug 12, 2026
This PR was opened by the [Changesets
release](https://github.com/changesets/action) GitHub action. When
you're ready to do a release, you can merge this and the packages will
be published to npm automatically. If you're not ready to do a release
yet, that's fine, whenever you add more changesets to main, this PR will
be updated.


# Releases
## @pythoughts/pythinker-code@0.16.0

### Minor Changes

- [#59](#59)
[`6999b68`](6999b68)
- Add an opt-in advisor: a second model reviews the conversation after a
completed user turn unless another review is already running, and its
notes appear as an `<advisory>` block in the agent's next turn; enable
with `[advisor] enabled = true` plus an advisor model (the `advisor`
model role or `[advisor] model`), and it runs only when the advisor
shares the session model's provider.

- [#56](#56)
[`b71f094`](b71f094)
- Add model roles: lock a model alias to the small, implementer, or
advisor slot with `/model <role>`, list assignments with `/model roles`,
and reference roles as `@small`, `@implementer`, or `@advisor` wherever
a subagent model can be set; an assigned implementer role becomes the
default model for subagents.

- [#57](#57)
[`99c427c`](99c427c)
- 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`.

- [#58](#58)
[`065bf2e`](065bf2e)
- Redesign core TUI surfaces: tool cards get state-tinted backgrounds
with three new theme tokens, a status bar with a per-session accent
color appears between the input box and footer, and the prompt box uses
a neutral border while permission mode appears in the status bar. The
working-label shimmer uses a calmer constant-velocity sweep with
alternating mission-control highlights.

### Patch Changes

- [#62](#62)
[`7fc36fd`](7fc36fd)
- Repair invalid escape sequences and unescaped quotes in model-written
tool arguments instead of failing the tool call.

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant