Skip to content

fix(reminder): inject system-reminder via context hook, not tool_result#19

Closed
any-victor wants to merge 2 commits into
tintinweb:masterfrom
victor-software-house:master
Closed

fix(reminder): inject system-reminder via context hook, not tool_result#19
any-victor wants to merge 2 commits into
tintinweb:masterfrom
victor-software-house:master

Conversation

@any-victor

@any-victor any-victor commented May 8, 2026

Copy link
Copy Markdown
Contributor

Closes #20.

Problem

The system-reminder injection currently appends a <system-reminder> text block onto unrelated tool_result content:

// src/index.ts
return {
  content: [...event.content, { type: "text" as const, text: SYSTEM_REMINDER }],
};

This corrupts model-visible transcript semantics for any tool that runs when the reminder is due (read, bash, grep, find, …): the reminder appears as if it were part of the tool's actual output. Real consequences:

  • transcript debugging becomes painful (which line came from the tool, which from us?)
  • the model gets a false attribution: it sees <system-reminder> 'inside' bash output even though bash never wrote it
  • pi's session JSONL ends up with task-management policy text wedged into unrelated tool blobs

tool_result is appropriate for observing or rewriting that tool's own output; it isn't appropriate for injecting policy/task-management reminders unrelated to what the tool produced.

Fix

Use two hooks:

  1. pi.on("tool_result", …) for cadence tracking only — never mutates content.
  2. pi.on("context", …) to inject the transient reminder into the upcoming LLM call's messages. context fires once before each LLM request (including mid-agent-loop after tool results have been appended), and the returned messages are used only for that one request — not persisted in the session store and not appended to any tool's output.

The reminder is appended as a role: "user" text message so models that don't support custom message types still receive it.

What changes

  • src/reminder-cadence.ts (new): pure cadence logic factored out for unit-testability.
    • createCadenceState, resetCadenceState, onTurnStart
    • evaluateToolResult(state, toolName, hasTasks, config)
    • drainReminderForContext(state)
  • src/index.ts: hook wiring uses the helpers; tool_result no longer mutates content; new context handler injects when due.
  • test/reminder-cadence.test.ts (new): 9 cases covering the decision logic.
  • pnpm-workspace.yaml (new): allowBuilds for @google/genai, koffi, protobufjs.

Test plan

pnpm install --no-frozen-lockfile
pnpm run typecheck    # clean
pnpm run lint         # clean
pnpm run test         # 157 / 157 passing across 6 files

Cases pinned in the new test file:

  • reminder marks due after REMINDER_INTERVAL non-task turns when tasks exist
  • never marks due when no tasks
  • never marks due before interval elapses
  • task tool usage resets cadence and clears any pending reminder
  • no re-fire within the same injection cycle
  • re-arms after task tool usage resets cycle
  • drainReminderForContext is one-shot per cycle
  • resetCadenceState wipes all four fields

Behaviour preserved

  • REMINDER_INTERVAL = 4 turns
  • task tool usage resets cadence and clears any pending reminder
  • never injects when no tasks exist
  • never re-fires inside a cycle until a task tool resets it
  • session_switch (new/resume) wipes cadence state

The system-reminder injection used to append a <system-reminder> text
block onto unrelated tool_result content. That corrupted model-visible
transcript semantics for any tool that ran when the reminder was due
(read, bash, grep, find, ...) \u2014 the reminder appeared as if it were
part of the tool's actual output, making transcript debugging painful
and giving the model a false attribution.

tool_result is the wrong hook for policy/task-management reminders. The
right hook is context: it fires once before each LLM call (including
mid-agent-loop after tool results have been appended), and the returned
messages are used only for that one request, never persisted in the
session store and never appended to any tool's output.

Refactored cadence into src/reminder-cadence.ts as a small set of pure
functions so the decision logic is unit-testable without spinning up a
fake ExtensionAPI:

  createCadenceState        initial state
  resetCadenceState         wipe on session_switch
  onTurnStart               increment turn counter
  evaluateToolResult        decide if reminder should queue (mutates state)
  drainReminderForContext   one-shot drain when context fires

The extension default export wires these into the live hooks:

  pi.on("tool_result", \u2026)  cadence tracking only, never mutates content
  pi.on("context", \u2026)      injects the reminder as a transient user
                            message into the upcoming LLM call's
                            messages array, never persisted

Tests: 9 new cases in test/reminder-cadence.test.ts cover:
  - reminder marks due after REMINDER_INTERVAL non-task turns when tasks exist
  - never marks due when no tasks
  - never marks due before interval elapses
  - task tool usage resets cadence and clears any pending reminder
  - no re-fire within the same injection cycle
  - re-arms after task tool usage resets cycle
  - drainReminderForContext is one-shot per cycle
  - resetCadenceState wipes all four fields

Suite: 157 tests across 6 files, all passing.

Refs: documented reproduction + design discussion in
~/.pi/docs/engineering/tintin-packages-issues.md (Issue 2).

Build hygiene
-------------
Add pnpm-workspace.yaml with allowBuilds for @google/genai, koffi,
protobufjs so installs don't gate on interactive approval.
pe200012 added a commit to pe200012/pi-tasks that referenced this pull request May 16, 2026
Adapt the reminder cadence refactor to preserve PR tintinweb#15 auto-cleared task ID reminders and missing-task hardening.
@tintinweb

tintinweb commented May 30, 2026

Copy link
Copy Markdown
Owner

manually merged see c04674b (master), thanks! 🙏

@tintinweb tintinweb closed this May 30, 2026
XertroV added a commit to clankercode/pi-tasks that referenced this pull request Jun 20, 2026
Task IDs are stored as bare numbers; the "#" was purely a display prefix
that GitHub auto-links to issues/PRs in any rendered text. Switch the
human-facing format to "T123" and centralize format/parse in a new
src/task-ref.ts (formatTaskRef + TASK_REF_RE), routing all previously
inlined "#"+id sites through it. No storage/schema change. Genuine GitHub
PR refs in CHANGELOG ((tintinweb#19) etc.) left untouched.
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.

<system-reminder> injected via tool_result corrupts unrelated tool output

2 participants