feat: implement provider-side prompt caching and workflow scaffolding
ci / check (macos-latest) (push) Canceled after 0s
ci / check (ubuntu-latest) (push) Canceled after 0s
ci / check (windows-latest) (push) Canceled after 0s

- Added support for caching the stable prefix of the system prompt for Anthropic models.
- Introduced a new option `cacheSystemPrefix` in SessionOptions to enable caching.
- Implemented a nudge escalation system that reminds users to update the TODO.md file, capped at three nudges.
- Created a new `scaffoldWorkflowFiles` function to generate TODO.md, ROADMAP.md, and docs/ directory when they are missing.
- Updated the App component to scaffold workflow files during initialization.
- Added hooks functionality to allow external scripts to modify tool input and manage approvals.
- Implemented tests for the new hooks functionality, ensuring proper approval and execution flow.
This commit is contained in:
asepharyana
2026-09-09 20:11:23 +07:00
parent 4aeb0d2455
commit 7a1e415fea
12 changed files with 884 additions and 32 deletions
+53 -14
View File
@@ -3,6 +3,7 @@ import {
generateText,
streamText,
APICallError,
type Instructions as SdkInstructions,
type LanguageModel,
type ModelMessage,
type ToolApprovalResponse,
@@ -106,10 +107,12 @@ export type SessionOptions = {
/** Live stdout/stderr from bash, for a UI that wants progress. */
onToolOutput?: (id: string, chunk: string) => void;
onNotebookChange?: (state: NotebookState) => void;
/** Cheaper model for background learning; falls back to main model. */
/** Cheap model for background learning; falls back to main model. */
learnerModel?: LanguageModel;
/** Emit learner notices to the UI. */
onNotice?: (text: string) => void;
/** Split the system prompt and mark the stable head cacheable (Anthropic cache_control). */
cacheSystemPrefix?: boolean;
/** Ignore-aware file list injected into the system prompt at boot; gitignore-respected. */
workspaceFiles?: readonly string[];
/** Project-driven workflow: TODO/ROADMAP tracking + verify-before-done nudges. */
@@ -244,8 +247,8 @@ export class Session {
private turnCappedNotice: string | undefined;
/** Did the current turn call todo_write? Gates the workflow nudge. */
private todoWrittenThisTurn = false;
/** Fired at most once per session: an agent that edits without updating the task list. */
private workflowNudged = false;
/** How many times this session has nudged about the task list; capped at 3. */
private workflowNudgeCount = 0;
/** Line count of TODO.md at last check, for /workflow. */
private workflowTodoLines = 0;
private workflowRoadmapLines = 0;
@@ -504,7 +507,7 @@ export class Session {
'This repo tracks its own progress. When you start real work here:',
'- read TODO.md (task list) before starting and keep it current as you go: mark what you did',
'- keep ROADMAP.md current when you ship a milestone',
'- for anything non-trivial, write a short plan first (spec-first), then code',
'- for anything non-trivial, load the `plan` skill (spec-first) and write a short plan before code',
'- add tests alongside code; this project expects complete unit tests, not just happy paths',
'- verify with the project\'s check commands (tests/typecheck/build) before declaring done',
];
@@ -517,13 +520,13 @@ export class Session {
}
/**
* One soft line after a turn that wrote files without touching the task
* list. Not a stop — it keeps the agent moving while reminding it the
* project expects the plan kept current. Fires at most once per session.
* A gentle ladder of reminders after turns that wrote files without touching
* the task list. Cap at three — a nag that repeats without limit trains the
* model to ignore it, so the third is explicitly the last.
*/
private workflowNudge(): string | undefined {
if (this.opts.workflow?.enabled === false) return undefined;
if (this.workflowNudged) return undefined;
if (this.workflowNudgeCount >= 3) return undefined;
const root = this.gitRoot();
if (!root) return undefined;
if (!this.workflowChecked) this.workflowPolicy();
@@ -536,8 +539,13 @@ export class Session {
// Only when onBeforeWrite actually fired (a write succeeded) and no todo_write
const hasWrites = this.turnBeforeFiles?.size > 0;
if (!hasWrites) return undefined;
this.workflowNudged = true;
return 'reminder: you modified files without updating the project task list (TODO.md). Keep it current: mark what you did.';
this.workflowNudgeCount += 1;
const messages = [
'reminder: you modified files without updating the project task list (TODO.md). Keep it current: mark what you did.',
'still no update to TODO.md. The project expects its task list kept current as you work.',
'last reminder: update TODO.md when you have a moment. This is the final nudge for this session.',
] as const;
return messages[this.workflowNudgeCount - 1];
}
/**
@@ -595,7 +603,7 @@ export class Session {
roadmapLines: number;
hasDocs: boolean;
docsFiles: number;
nudged: boolean;
nudgeCount: number;
} {
const root = this.gitRoot();
if (!this.workflowChecked && root) this.workflowPolicy();
@@ -607,7 +615,7 @@ export class Session {
roadmapLines: this.workflowRoadmapLines,
hasDocs: this.workflowHasDocs,
docsFiles: this.workflowDocsFiles,
nudged: this.workflowNudged,
nudgeCount: this.workflowNudgeCount,
};
}
@@ -756,7 +764,7 @@ export class Session {
return names.size > 0 ? [...names].sort() : undefined;
}
private systemFor(): string {
private systemFor(): SdkInstructions {
const versionKey = [
`nb:${this.versions.notebook}`,
`mem:${this.versions.memory}`,
@@ -790,7 +798,38 @@ export class Session {
...(workflowPolicy ? { workflowPolicy } : {}),
});
this.promptCache = { key: versionKey, text };
return text;
return this.withCacheBreak(text);
}
/**
* Provider-side prompt caching, when the provider rewards a stable prefix.
*
* The system prompt is split into a stable head (instructions, tools, policy,
* skills) and a volatile tail (notebook, memory — the parts a todo_write or a
* remember call changes mid-session). Anthropic supports `cache_control` on a
* system block, so the stable head is marked ephemeral and the volatile tail
* rides along without invalidating it. OpenAI's automatic prefix caching
* needs nothing here; this is a no-op unless cacheSystemPrefix is set, which
* cli.tsx only does for the anthropic provider.
*/
private withCacheBreak(text: string): SdkInstructions {
if (!this.opts.cacheSystemPrefix) return text;
// The split point is the stable head. The volatile suffix starts at the
// notebook block (`Your task list`) — everything before it is instructions
// and policy built from versions that rarely change.
const marker = '\nYour task list';
const idx = text.indexOf(marker);
if (idx <= 0) return text;
const stable = text.slice(0, idx);
const volatile = text.slice(idx);
return [
{
role: 'system',
content: stable,
providerOptions: { anthropic: { cacheControl: { type: 'ephemeral' } } },
},
{ role: 'system', content: volatile },
];
}
/**