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
52 changes: 51 additions & 1 deletion packages/agent-core-v2/src/human/agent/machine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ export type AgentEvent =
| { type: 'input.steer'; id: string }
| { type: 'input.cancel'; id: string }
| { type: 'input.abort' }
| { type: 'input.pause' }
| { type: 'input.continue' }
| { type: 'turn.spawn_tools'; toolCalls: ToolCall[] }
| { type: 'turn.drain' }
| { type: 'turn.reminders_consumed'; reminders: HistoryMessage[] }
Expand Down Expand Up @@ -89,6 +91,7 @@ export interface AgentMachineContext {
activeTurnId?: number;
branchId: string;
drainedId?: string;
paused: boolean;
}

function completionNotification(toolCall: ToolCall, output: ToolOutput): UserEntry {
Expand Down Expand Up @@ -169,6 +172,13 @@ function hasPendingWork(context: AgentMachineContext): boolean {
return context.notifications.length > 0 || context.queue.length > 0;
}

function historyEndsMidToolChain(messages: readonly HistoryMessage[]): boolean {
const last = messages.at(-1);
if (last === undefined) return false;
if (last.message.role === 'tool') return true;
return last.message.role === 'assistant' && last.message.toolCalls.length > 0;
}

function hasBackgroundWork(context: AgentMachineContext): boolean {
return Object.keys(context.background).length > 0;
}
Expand Down Expand Up @@ -336,6 +346,7 @@ export function createAgentMachine({
queue: [],
turnId: 0,
branchId: 'main',
paused: false,
}),
invoke: [
{
Expand Down Expand Up @@ -430,6 +441,12 @@ export function createAgentMachine({
target: '.idle',
actions: ['abortScope', 'resetMirror', 'emitReset', 'forwardToParent'],
},
'input.pause': {
actions: assign({ paused: true }),
},
'input.continue': {
actions: assign({ paused: false }),
},
'store.error': {
actions: 'forwardToParent',
},
Expand Down Expand Up @@ -469,7 +486,7 @@ export function createAgentMachine({
idle: {
initial: 'ready',
always: {
guard: ({ context }) => hasPendingWork(context),
guard: ({ context }) => hasPendingWork(context) && !context.paused,
target: 'running',
actions: [
sendTo('store', ({ context }) => {
Expand All @@ -491,6 +508,33 @@ export function createAgentMachine({
assign(({ context }) => drainPendingPatch(context)),
],
},
on: {
'input.continue': {
guard: ({ context }) =>
!hasPendingWork(context) && historyEndsMidToolChain(context.messages),
target: 'running',
actions: [
assign({ paused: false }),
sendTo('store', ({ context }) => {
const head = context.queue[0];
return {
type: 'store.append' as const,
event: [
...context.notifications.map((entry) => messageAppended({ message: entry })),
...(head === undefined
? []
: [
messageAppended({ message: createUserEntry(head.message, { source: 'input' }) }),
queueDrained({ id: head.id }),
]),
...(context.notifications.length === 0 ? [] : [notificationsDrained({})]),
],
};
}),
assign(({ context }) => drainPendingPatch(context)),
],
},
},
states: {
ready: {
always: {
Expand Down Expand Up @@ -581,6 +625,12 @@ export function createAgentMachine({
'forwardToParent',
],
},
'input.pause': {
actions: [assign({ paused: true }), sendTo('turn', { type: 'turn.pause' as const })],
},
'input.continue': {
actions: [assign({ paused: false }), sendTo('turn', { type: 'turn.continue' as const })],
},
'turn.drain': {
actions: enqueueActions(({ context, enqueue }) => {
const messages = [...context.notifications, ...context.reminders];
Expand Down
81 changes: 66 additions & 15 deletions packages/agent-core-v2/src/human/agent/turn.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { assign, raise, setup } from '#/xstate2';
import { assign, fromPromise, raise, setup } from '#/xstate2';

import { emptyResponseError } from '#/llm/empty-response';
import type { LlmErrorMessage, LlmRemoteErrorMessage } from '#/llm/errors';
Expand Down Expand Up @@ -187,6 +187,8 @@ export type TurnEvent =
| LlmEvent
| TurnToolEvent
| { type: 'turn.notify'; messages: HistoryMessage[] }
| { type: 'turn.pause' }
| { type: 'turn.continue' }
| { type: 'turn.abort' };

export type TurnLlmEvent =
Expand Down Expand Up @@ -217,6 +219,7 @@ export interface TurnMachineContext {
attempt: number;
delayMs: number;
appliedRecoveries: LlmRecoveryRecord[];
paused: boolean;
lastError?: LlmRemoteErrorMessage;
outcome?: 'done' | 'failed' | 'aborted';
error?: unknown;
Expand Down Expand Up @@ -343,10 +346,18 @@ function emptyErrorOf(context: TurnMachineContext): LlmErrorMessage<'empty_respo
);
}

export interface TurnBeforeStepContext {
messages: readonly HistoryMessage[];
request: LlmRequestConfig;
}

export type TurnBeforeStep = (context: TurnBeforeStepContext) => void | Promise<void>;

export interface CreateTurnMachineOptions {
readonly recovery?: LlmRecovery;
readonly retry?: LlmRetryOptions;
readonly abortGraceMs?: number;
readonly onBeforeStep?: TurnBeforeStep;
}

export function createTurnMachine(
Expand All @@ -365,6 +376,9 @@ export function createTurnMachine(
},
actors: {
llmActor,
onBeforeStepActor: fromPromise<void, TurnBeforeStepContext>(async ({ input }) => {
await options?.onBeforeStep?.(input);
}),
},
actions: {
forwardToParent: ({ self, event }) => {
Expand Down Expand Up @@ -403,7 +417,7 @@ export function createTurnMachine(
},
}).createMachine({
id: 'turn',
initial: 'thinking',
initial: 'gating',
context: ({ input }) => {
const toolCallIds = new ToolCallIdNormalizer();
toolCallIds.seedFrom(toInputMessages(input.history));
Expand All @@ -420,9 +434,36 @@ export function createTurnMachine(
attempt: 1,
delayMs: 0,
appliedRecoveries: [],
paused: false,
};
},
on: {
'turn.pause': {
actions: assign({ paused: true }),
},
'turn.continue': {
actions: assign({ paused: false }),
},
},
states: {
gating: {
always: [{ guard: () => options?.onBeforeStep === undefined, target: 'thinking' }],
invoke: {
src: 'onBeforeStepActor',
input: ({ context }) => ({
messages: [...context.input.history, ...context.produced],
request: context.input.request,
}),
onDone: { target: 'thinking' },
onError: { target: 'done' },

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Do not mark blocked steps done before compaction succeeds

When the budget hook rejects, this transition completes the turn as done with no response while compaction is only scheduled asynchronously. If the automatic compaction then fails—for example because the summary request times out—the cancellation path merely unpauses the agent; the initiating prompt has already been drained into history, there is no pending work, and no new turn starts, so the user receives neither an answer nor a turn failure. Preserve/retry the blocked turn or propagate the hook failure unless compaction successfully schedules its continuation.

Useful? React with 👍 / 👎.

},
on: {
'turn.abort': {
target: 'aborted',
actions: assign({ outcome: 'aborted' as const }),
},
},
},
thinking: {
entry: [
assign({
Expand All @@ -441,20 +482,20 @@ export function createTurnMachine(
],
invoke: {
src: 'llmActor',
input: ({ context }) => {
const entries = [...context.input.history, ...context.produced];
return {
config: context.input.request,
signal: context.llmScope.signal,
content: {
messages: attemptMessages(context, recovery),
usedContextTokens: estimateUsedContextTokens(entries, {
input: ({ context }) => ({
config: context.input.request,
signal: context.llmScope.signal,
content: {
messages: attemptMessages(context, recovery),
usedContextTokens: estimateUsedContextTokens(
[...context.input.history, ...context.produced],
{
systemPrompt: context.input.request.systemPrompt,
tools: context.input.request.tools,
}),
},
};
},
},
),
},
}),
onError: {
target: 'failed',
actions: assign({
Expand Down Expand Up @@ -778,6 +819,16 @@ export function createTurnMachine(
},
on: {
'turn.notify': [
{
guard: ({ context }) => context.paused,
target: 'done',
actions: [
assign(({ context, event }) => ({
produced: [...context.produced, ...event.messages],
})),
'signalRemindersConsumed',
],
},
{
guard: ({ context, event }) =>
event.messages.length === 0 && maxStepsExceeded(context),
Expand All @@ -788,7 +839,7 @@ export function createTurnMachine(
})),
},
{
target: 'thinking',
target: 'gating',
actions: [
assign(({ context, event }) => ({
produced: [...context.produced, ...event.messages],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
You are about to run out of context. Create a handoff summary for the
model that will resume this task after the earlier conversation is cleared.

--- This message is a direct task, not part of the above conversation ---

Do not impose rigid section headings; let the shape follow the task. Write it
in the same language the conversation has been using — do not switch to English
just because these instructions happen to be in English.

Make the summary self-sufficient: the next turn will see only the preserved
messages and this summary — every other assistant message, tool call, and tool
result above will be gone. In your own words, preserve what you genuinely need
to continue:

- What the latest request is actually asking for: your reading of its intent and
any ambiguity you have already resolved — not a re-transcription, since what
fits is kept verbatim in the preserved messages. But those kept messages are
size-capped, so a long request is truncated there: if the latest request is
large (a big paste or file), preserve the parts at risk of being dropped —
above all the actual ask. If several requests are in play, say which one governs
the next move, and re-quote any still-relevant earlier request that may have
scrolled out of the kept messages.
- The instructions and constraints currently in force (user preferences,
project rules, environment and tooling limits) — condensed to what still
matters, keeping decisions you have already settled (what you chose and why)
separate from questions still open, so you neither silently reopen a closed
choice nor treat an undecided point as decided.
- What has actually been done, at high fidelity: keep the exact commands that
were run, the exact file paths touched, and whether each succeeded or failed —
and the results themselves, not just the commands: the concrete values
returned, the key lines or error text, the schema or signature a lookup
revealed, since re-running to recover them may be slow or impossible. Keep only
the final working version of any code; drop intermediate attempts and
already-resolved errors.
- What you still don't know: context the next step depends on that this
conversation never established — files or paths referenced but not yet read,
schemas or APIs assumed but unseen, questions the user has not answered. Name
these gaps so the next turn goes and checks them instead of assuming.
- The forward plan — and this is the moment to invest in it. Right now you
hold more context on this task than you ever will again; the next turn
resumes with less, so the plan you commit here is the one it will follow.
Give the exact next command or tool call, but don't stop at the next step:
set out the remaining sequence to finish, the decisions you have already
made for those upcoming steps (so the next turn doesn't reopen them), the
obstacles or edge cases you can already foresee and how you mean to handle
them, and any work you can commit to now — the exact patch, query, or shape
of the final answer you already know you will produce. Anything you settle
here is one less thing the next turn must rediscover. Include any required
format for the final answer.

This conversation's event log stays on disk and a recovery pointer is appended below this summary automatically, so you need not reproduce long outputs verbatim — keep exact identifiers, key values and error lines, and name anything the next turn should look up.

Your TODO list is re-attached automatically below this summary from its live
source, so do not transcribe it — copying it wastes space and can contradict the
live version. What that list cannot hold is the reasoning between tasks — why one
was reordered or dropped, or a decision on one that constrains another — so
record that instead.

Be honest about uncertainty. If an earlier step claimed something was done but
was never verified (tests "passing", a fix "working", a file "created"), say so
plainly and treat it as unverified rather than fact — re-check before relying
on it.

Be concise, and keep the summary proportional to the task: a long multi-step
task warrants detail, but a trivial or nearly finished exchange needs only a
sentence or two — do not pad it out. Include the critical data, identifiers, and
references needed to continue, and omit anything that does not change the next
move.

Respond with text only. Do not call any tools — you already have everything you
need in the conversation history.

${custom_instruction_block}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The conversation so far has been compacted to free up context. What follows is your own working summary of this task — use it to continue your train of thought rather than starting over. Treat it as notes, not proof: where it says a step was done, tests passed, or a fix worked, verify that yourself before relying on it. Any user messages earlier in this context are preserved verbatim from the compacted conversation; where a system-reminder note among them marks an omitted middle section, the user messages it replaced are covered by this summary. The summary records which earlier requests were already addressed.
Loading
Loading