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
21 changes: 20 additions & 1 deletion common/src/templates/initial-agents-dir/types/util-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,11 +100,30 @@ export type AuxiliaryMessageData = {
*/
sentAt?: number

/**
* Declarative expiry boundary; see AgentExpiresAt in the agent runtime's
* cache-expiry.ts. Duplicated inline here so the scaffolded types stay
* self-contained (they cannot import across packages).
*/
expiresAt?:
| 'agentStep'
| 'userPrompt'
| 'turn'
| 'conversation'
| 'notImplemented'

/** @deprecated Use tags instead. */
timeToLive?: 'agentStep' | 'userPrompt'
/** @deprecated Use tags instead. */
keepDuringTruncation?: boolean
/** @deprecated Use tags instead. */
/**
* When a message with `keepLastTags` arrives, the latest declaration wins:
* earlier messages that share any of the listed tags (a declaration's own
* `tags` overlap counts too) are dropped from the assembled history by the
* runtime's per-step expiry pass.
*
* @deprecated Use tags instead.
*/
keepLastTags?: string[]
}

Expand Down
20 changes: 19 additions & 1 deletion common/src/types/messages/codebuff-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
ToolCallPart,
ToolResultOutput,
} from './content-part'
import type { AgentExpiresAt } from '../../../../packages/agent-runtime/src/cache-expiry'
import type { ProviderMetadata } from './provider-metadata'

export type AuxiliaryMessageData = {
Expand All @@ -19,13 +20,30 @@ export type AuxiliaryMessageData = {
*/
sentAt?: number

/**
* Declarative expiry boundary. Unlike `timeToLive`, a message carrying this
* still informs its own next step: `expiresAt` is only applied on the step
* after the one that observed the message. `timeToLive: 'agentStep'`, by
* contrast, is removed by every step boundary — including the one right
* after the message was pushed mid-turn — which is why the step prompt must
* be re-pushed (rather than merely re-stamped) each step.
*/
expiresAt?: AgentExpiresAt

// James: All the below is overly prescriptive for the framework.
// Instead, let's tag what the message is, and let the user decide time to live, keep during truncation, etc.
/** @deprecated Use tags instead. */
timeToLive?: 'agentStep' | 'userPrompt'
/** @deprecated Use tags instead. */
keepDuringTruncation?: boolean
/** @deprecated Use tags instead. */
/**
* When a message with `keepLastTags` arrives, the latest declaration wins:
* earlier messages that share any of the listed tags (a declaration's own
* `tags` overlap counts too) are dropped from the assembled history.
* Implemented in the agent runtime's per-step/expiry pass (expireMessages).
*
* @deprecated Use tags instead.
*/
keepLastTags?: string[]
}

Expand Down
86 changes: 65 additions & 21 deletions packages/agent-runtime/src/__tests__/loop-agent-steps.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,9 +202,12 @@ describe('loopAgentSteps - runAgentStep vs runProgrammaticStep behavior', () =>
},
})
expect(calls).toBe(4)
// The check is turn-scoped: the model sees it from the step that pushes
// it to the end of the turn, and it does not outlive the turn.
expect(prompts.filter((p) => p.includes('Final completion check:'))).toEqual(
[prompts[2], prompts[3]],
)
expect(prompts[1]).not.toContain('Final completion check:')
expect(prompts[2]).toContain('Final completion check:')
expect(prompts[3].split('Final completion check:')).toHaveLength(2)
})

it('does not add a completion check to a plain answer or exceed the step budget', async () => {
Expand Down Expand Up @@ -603,13 +606,14 @@ describe('loopAgentSteps - runAgentStep vs runProgrammaticStep behavior', () =>
else {
// Repairs are reachable only if the real loop resumes after
// the premature answer and supplies the completion check.
// Turn-scoped expiry keeps the check visible until the turn
// ends, so only the first repair prompt must contain it.
if (index === before.length + 1)
completionPrompts = (
JSON.stringify(messages).match(
/Final completion check:/g,
) ?? []
).length
if (completionPrompts !== 1)
throw new Error('Expected exactly one completion check')
const next = repair[index - before.length - 1]
if (next) yield next
else
Expand All @@ -621,6 +625,7 @@ describe('loopAgentSteps - runAgentStep vs runProgrammaticStep behavior', () =>
return promptSuccess(`verification-${calls}`)
},
})
expect((result.output as { message?: string }).message).toBe(undefined)
expect(result.output.type).not.toBe('error')
expect(calls).toBe(
before.length + 1 + (enabled ? repair.length + 1 : 0),
Expand Down Expand Up @@ -867,10 +872,19 @@ describe('loopAgentSteps - runAgentStep vs runProgrammaticStep behavior', () =>
completed: i < doneCount,
})),
})
const nudges = (state: AgentState) =>
state.messageHistory.filter((m) =>
m.tags?.includes(FOLLOWUP_TODO_NUDGE_TAG),
).length
// Turn-scoped expiry removes a turn's nudges from the returned state,
// so count the pushes the model saw: a push increases the running count
// of nudge messages by one, and with turn-scoped expiry that count only
// grows during the turn.
const nudges = (prompts: string[]) =>
Math.max(
0,
...prompts.map(
(p) =>
(p.match(/to-do list still has \d+ unfinished items?/g) ?? [])
.length,
),
)

beforeEach(() => {
mockTemplate.toolNames.push('write_todos', 'suggest_followups')
Expand Down Expand Up @@ -908,16 +922,18 @@ describe('loopAgentSteps - runAgentStep vs runProgrammaticStep behavior', () =>
'to-do list still has 2 unfinished items: "Step 2", "Step 3"',
).slice(1, -1),
)
expect(nudges(result.agentState)).toBe(1)
expect(nudges(seenPrompts)).toBe(1)
// The unfinished list wins over asking for the answer.
expect(seenPrompts.join('\n')).not.toContain(answerNote)
})

it('ends on cards when every to-do is done', async () => {
let calls = 0
const seenPrompts: string[] = []
const result = await loopAgentSteps({
...loopAgentStepsBaseParams,
promptAiSdkStream: async function* () {
promptAiSdkStream: async function* ({ messages }) {
seenPrompts.push(JSON.stringify(messages))
if (++calls === 1) {
yield list(3)
} else {
Expand All @@ -928,7 +944,7 @@ describe('loopAgentSteps - runAgentStep vs runProgrammaticStep behavior', () =>
},
})
expect(calls).toBe(2)
expect(nudges(result.agentState)).toBe(0)
expect(nudges(seenPrompts)).toBe(0)
})

it('asks for the answer when the cards come without one', async () => {
Expand All @@ -948,14 +964,16 @@ describe('loopAgentSteps - runAgentStep vs runProgrammaticStep behavior', () =>
expect(seenPrompts).toHaveLength(2)
expect(seenPrompts[0]).not.toContain(answerNote)
expect(seenPrompts[1]).toContain(answerNote)
expect(nudges(result.agentState)).toBe(0)
expect(nudges(seenPrompts)).toBe(0)
})

it('lets the model stop after one nudge when it makes no progress', async () => {
let calls = 0
const seenPrompts: string[] = []
const result = await loopAgentSteps({
...loopAgentStepsBaseParams,
promptAiSdkStream: async function* () {
promptAiSdkStream: async function* ({ messages }) {
seenPrompts.push(JSON.stringify(messages))
if (++calls === 1) {
yield list(1)
} else {
Expand All @@ -969,15 +987,17 @@ describe('loopAgentSteps - runAgentStep vs runProgrammaticStep behavior', () =>
},
})
expect(calls).toBe(3)
expect(nudges(result.agentState)).toBe(1)
expect(nudges(seenPrompts)).toBe(1)
})

it(`stops nudging after ${MAX_FOLLOWUP_TODO_NUDGES} times in one prompt`, async () => {
mockAgentState.stepsRemaining = 50
let calls = 0
const seenPrompts: string[] = []
const result = await loopAgentSteps({
...loopAgentStepsBaseParams,
promptAiSdkStream: async function* () {
promptAiSdkStream: async function* ({ messages }) {
seenPrompts.push(JSON.stringify(messages))
calls++
// Progress every cycle (one more item, one more done) but never the
// whole list.
Expand All @@ -987,7 +1007,7 @@ describe('loopAgentSteps - runAgentStep vs runProgrammaticStep behavior', () =>
return promptSuccess(`cap-${calls}`)
},
})
expect(nudges(result.agentState)).toBe(MAX_FOLLOWUP_TODO_NUDGES)
expect(nudges(seenPrompts)).toBe(MAX_FOLLOWUP_TODO_NUDGES)
expect(calls).toBe(2 * (MAX_FOLLOWUP_TODO_NUDGES + 1))
})

Expand All @@ -1013,18 +1033,20 @@ describe('loopAgentSteps - runAgentStep vs runProgrammaticStep behavior', () =>
},
]
let calls = 0
const seenPrompts: string[] = []
const result = await loopAgentSteps({
...loopAgentStepsBaseParams,
prompt: 'What does this file do?',
promptAiSdkStream: async function* () {
promptAiSdkStream: async function* ({ messages }) {
seenPrompts.push(JSON.stringify(messages))
calls++
yield { type: 'text', text: answer }
yield followups()
return promptSuccess(`question-${calls}`)
},
})
expect(calls).toBe(1)
expect(nudges(result.agentState)).toBe(0)
expect(nudges(seenPrompts)).toBe(0)
})
})

Expand Down Expand Up @@ -1052,8 +1074,24 @@ describe('loopAgentSteps - runAgentStep vs runProgrammaticStep behavior', () =>
})

expect(requests).toHaveLength(2)
expect(requests[1].slice(0, requests[0].length)).toEqual(requests[0])
expect(JSON.stringify(requests[1]).split('Test user prompt')).toHaveLength(3)
// Turn 2 starts from turn 1 minus the superseded instructions
// (keepLastTags): the first divergence is where turn 1's declared-stale
// instructions message was pruned.
const firstDivergence = requests[0].findIndex(
(message, i) =>
JSON.stringify(message) !== JSON.stringify(requests[1][i]),
)
// Turn 2's request diverges from turn 1 exactly where turn 1's
// keepLastTags-superseded instructions were pruned; everything earlier
// (the long stable prefix the prompt cache depends on) is untouched.
expect(firstDivergence).toBeGreaterThan(-1)
expect(JSON.stringify(requests[0][firstDivergence])).toContain(
'INSTRUCTIONS_PROMPT',
)
// Turn 1's live turn carried user prompt + instructions (both); turn 2
// carries only the new pair, so the shared user prompt text appears once
// per request, not once per turn it was live in.
expect(JSON.stringify(requests[1]).split('Test user prompt')).toHaveLength(2)
})

describe('suggest_prompts before the answer', () => {
Expand Down Expand Up @@ -1215,11 +1253,17 @@ describe('loopAgentSteps - runAgentStep vs runProgrammaticStep behavior', () =>
expect(seen[12]).toContain('1 of 4 done')
// Ignored: never repeated for the same list.
expect(reminders(seen.at(-1)!)).toBe(2)
// Turn-scoped expiry removes the reminders from the returned state;
// what proves both were pushed is that the peak reminders any prompt
// carried is 2 and the last one still shows the second reminder.
expect(Math.max(...seen.map(reminders))).toBe(2)
// The next-turn boundary drops them: no stale reminder survives to be
// re-sent against a future, unrelated to-do list.
expect(
result.agentState.messageHistory.filter((m) =>
m.tags?.includes(TODO_PROGRESS_REMINDER_TAG),
),
).toHaveLength(2)
).toHaveLength(0)
})
})

Expand Down
37 changes: 37 additions & 0 deletions packages/agent-runtime/src/cache-expiry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/**
* Declarative expiry boundaries for messages in agent history, carried on a
* message's `expiresAt` field. The expiry pass is `expireMessages` in
* util/messages.ts.
*/
export type AgentExpiresAt =
/** Removed at the next step boundary after the step that observed it. */
| 'agentStep'
/**
* Removed only when a user prompt ends (or a turn-time-out closes the
* turn). Deprecated: prefer agentStep — every `endOf: 'userPrompt'` call
* site also runs after a turn's messages have been consumed, so surviving
* there buys nothing over 'agentStep'.
*/
| 'userPrompt'
/**
* Clean up at the end of an agent invocation, but don't keep during
* truncation. Everything before this is deleted after all messages after it
* are deleted (or none exist). For example, the prompt a subagent was
* spawned with should not get repeated (it matters less after a few steps),
* but it shouldn't just disappear and leave a hanging assistant message.
*
* Not yet implemented: treated as a message that never expires.
*/
| 'turn'
/**
* Everything is chunked, and the tags removed/reordered when summarizing
* from the original messages.
*
* Not yet implemented: treated as a message that never expires.
*/
| 'conversation'
/**
* For use with keepLastTags: we don't know when the agent deems it stale.
* Never expires on its own.
*/
| 'notImplemented'
3 changes: 3 additions & 0 deletions packages/agent-runtime/src/conversation-summary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,9 @@ export function summaryRequestHistory(messages: Message[]): Message[] {
function isEphemeral(message: Message): boolean {
return (
message.timeToLive !== undefined ||
(message.expiresAt !== undefined &&
message.expiresAt !== 'conversation' &&
message.expiresAt !== 'notImplemented') ||
message.tags?.includes('STEP_PROMPT') === true
)
}
Expand Down
9 changes: 9 additions & 0 deletions packages/agent-runtime/src/run-agent-step.ts
Original file line number Diff line number Diff line change
Expand Up @@ -895,6 +895,10 @@ export const runAgentStep = async (
userMessage({
content: withSystemTags(decision.message),
tags: [FOLLOWUP_TODO_NUDGE_TAG],
// Steers the current turn's plan; it must stay readable in
// every later step of THIS turn (the nudge decision counts it to
// rate-limit), and die with the turn.
expiresAt: 'userPrompt' as const,
}),
)
shouldEndTurn = false
Expand All @@ -919,6 +923,7 @@ export const runAgentStep = async (
userMessage({
content: withSystemTags(SUGGEST_PROMPTS_BEFORE_ANSWER_MESSAGE),
tags: [SUGGEST_PROMPTS_BEFORE_ANSWER_TAG],
expiresAt: 'userPrompt' as const,
}),
)
shouldEndTurn = false
Expand Down Expand Up @@ -946,6 +951,7 @@ export const runAgentStep = async (
userMessage({
content: withSystemTags(nudge.message),
tags: [GRAVITY_REPORT_NUDGE_TAG],
expiresAt: 'userPrompt' as const,
}),
)
shouldEndTurn = false
Expand Down Expand Up @@ -973,6 +979,7 @@ export const runAgentStep = async (
userMessage({
content: withSystemTags(reminder.message),
tags: [TODO_PROGRESS_REMINDER_TAG],
expiresAt: 'userPrompt' as const,
}),
)
}
Expand Down Expand Up @@ -1998,6 +2005,8 @@ export async function loopAgentSteps(
content: withSystemTags(
"Final completion check: compare the actual deliverables with the user request: exact paths, interfaces, formats, correctness, and any performance requirements. If a necessary check or repair is missing, use the available tools to finish it. Verify the final edited files; rerun affected checks after repairs, but do not repeat passing checks when the relevant files have not changed. Preserve the tested command's exit status when filtering output (for example, Bash pipefail or explicit status capture); a successful output filter is not a passing test. Fix causes rather than skipping tests, weakening assertions, swallowing errors, or adding type/lint suppressions just to pass. If changing a check is required by the requested behavior, explain why and verify that behavior. Respect permission boundaries, refusals, and requests to stop; if awaiting user input or genuinely blocked, report that and end. Otherwise give a concise final answer stating what was verified, which checks failed or could not run, and any remaining limitations.",
),
tags: ['COMPLETION_CHECK'],
expiresAt: 'userPrompt' as const,
}),
)
shouldEndTurn = false
Expand Down
Loading
Loading