Skip to content

Commit 996dccb

Browse files
committed
feat(agents): add structured execution item events
1 parent 3b7e615 commit 996dccb

11 files changed

Lines changed: 1130 additions & 48 deletions

src/agents/pi-embedded-runner/run.incomplete-turn.test.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@ import {
88
overflowBaseRunParams,
99
resetRunOverflowCompactionHarnessMocks,
1010
} from "./run.overflow-compaction.harness.js";
11-
import { resolvePlanningOnlyRetryInstruction } from "./run/incomplete-turn.js";
11+
import {
12+
extractPlanningOnlyPlanDetails,
13+
resolvePlanningOnlyRetryInstruction,
14+
} from "./run/incomplete-turn.js";
1215
import type { EmbeddedRunAttemptResult } from "./run/types.js";
1316

1417
let runEmbeddedPiAgent: typeof import("./run.js").runEmbeddedPiAgent;
@@ -97,4 +100,15 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => {
97100

98101
expect(retryInstruction).toBeNull();
99102
});
103+
104+
it("extracts structured steps from planning-only narration", () => {
105+
expect(
106+
extractPlanningOnlyPlanDetails(
107+
"I'll inspect the code. Then I'll patch the issue. Finally I'll run tests.",
108+
),
109+
).toEqual({
110+
explanation: "I'll inspect the code. Then I'll patch the issue. Finally I'll run tests.",
111+
steps: ["I'll inspect the code.", "Then I'll patch the issue.", "Finally I'll run tests."],
112+
});
113+
});
100114
});

src/agents/pi-embedded-runner/run.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
ensureContextEnginesInitialized,
66
resolveContextEngine,
77
} from "../../context-engine/index.js";
8+
import { emitAgentPlanEvent } from "../../infra/agent-events.js";
89
import { sleepWithAbort } from "../../infra/backoff.js";
910
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
1011
import { enqueueCommandInLane } from "../../process/command-queue.js";
@@ -82,6 +83,7 @@ import {
8283
} from "./run/helpers.js";
8384
import {
8485
resolveIncompleteTurnPayloadText,
86+
extractPlanningOnlyPlanDetails,
8587
resolvePlanningOnlyRetryInstruction,
8688
} from "./run/incomplete-turn.js";
8789
import type { RunEmbeddedPiAgentParams } from "./run/params.js";
@@ -1357,6 +1359,31 @@ export async function runEmbeddedPiAgent(
13571359
nextPlanningOnlyRetryInstruction &&
13581360
planningOnlyRetryAttempts < 1
13591361
) {
1362+
const planningOnlyText = attempt.assistantTexts.join("\n\n").trim();
1363+
const planDetails = extractPlanningOnlyPlanDetails(planningOnlyText);
1364+
if (planDetails) {
1365+
emitAgentPlanEvent({
1366+
runId: params.runId,
1367+
...(params.sessionKey ? { sessionKey: params.sessionKey } : {}),
1368+
data: {
1369+
phase: "update",
1370+
title: "Assistant proposed a plan",
1371+
explanation: planDetails.explanation,
1372+
steps: planDetails.steps,
1373+
source: "planning_only_retry",
1374+
},
1375+
});
1376+
void params.onAgentEvent?.({
1377+
stream: "plan",
1378+
data: {
1379+
phase: "update",
1380+
title: "Assistant proposed a plan",
1381+
explanation: planDetails.explanation,
1382+
steps: planDetails.steps,
1383+
source: "planning_only_retry",
1384+
},
1385+
});
1386+
}
13601387
planningOnlyRetryAttempts += 1;
13611388
planningOnlyRetryInstruction = nextPlanningOnlyRetryInstruction;
13621389
log.warn(

src/agents/pi-embedded-runner/run/incomplete-turn.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,11 @@ const PLANNING_ONLY_COMPLETION_RE =
3838
export const PLANNING_ONLY_RETRY_INSTRUCTION =
3939
"The previous assistant turn only described the plan. Do not restate the plan. Act now: take the first concrete tool action you can. If a real blocker prevents action, reply with the exact blocker in one sentence.";
4040

41+
export type PlanningOnlyPlanDetails = {
42+
explanation: string;
43+
steps: string[];
44+
};
45+
4146
export function buildAttemptReplayMetadata(
4247
params: ReplayMetadataAttempt,
4348
): EmbeddedRunAttemptResult["replayMetadata"] {
@@ -88,6 +93,36 @@ function shouldApplyPlanningOnlyRetryGuard(params: {
8893
return /^gpt-5(?:[.-]|$)/i.test(params.modelId ?? "");
8994
}
9095

96+
function extractPlanningOnlySteps(text: string): string[] {
97+
const lines = text
98+
.split(/\r?\n/)
99+
.map((line) => line.trim())
100+
.filter(Boolean);
101+
const bulletLines = lines
102+
.map((line) => line.replace(/^[-*]\s+|^\d+[.)]\s+/u, "").trim())
103+
.filter(Boolean);
104+
if (bulletLines.length >= 2) {
105+
return bulletLines.slice(0, 4);
106+
}
107+
return text
108+
.split(/(?<=[.!?])\s+/u)
109+
.map((step) => step.trim())
110+
.filter(Boolean)
111+
.slice(0, 4);
112+
}
113+
114+
export function extractPlanningOnlyPlanDetails(text: string): PlanningOnlyPlanDetails | null {
115+
const trimmed = text.trim();
116+
if (!trimmed) {
117+
return null;
118+
}
119+
const steps = extractPlanningOnlySteps(trimmed);
120+
return {
121+
explanation: trimmed,
122+
steps,
123+
};
124+
}
125+
91126
export function resolvePlanningOnlyRetryInstruction(params: {
92127
provider?: string;
93128
modelId?: string;

src/agents/pi-embedded-subscribe.handlers.tools.test.ts

Lines changed: 157 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,16 @@ function createTestContext(): {
1717
ctx: ToolHandlerContext;
1818
warn: ReturnType<typeof vi.fn>;
1919
onBlockReplyFlush: ReturnType<typeof vi.fn>;
20+
onAgentEvent: ReturnType<typeof vi.fn>;
2021
} {
2122
const onBlockReplyFlush = vi.fn();
23+
const onAgentEvent = vi.fn();
2224
const warn = vi.fn();
2325
const ctx: ToolHandlerContext = {
2426
params: {
2527
runId: "run-test",
2628
onBlockReplyFlush,
27-
onAgentEvent: undefined,
29+
onAgentEvent,
2830
onToolResult: undefined,
2931
},
3032
flushBlockReplyBuffer: vi.fn(),
@@ -59,7 +61,7 @@ function createTestContext(): {
5961
trimMessagingToolSent: vi.fn(),
6062
};
6163

62-
return { ctx, warn, onBlockReplyFlush };
64+
return { ctx, warn, onBlockReplyFlush, onAgentEvent };
6365
}
6466

6567
describe("handleToolExecutionStart read path checks", () => {
@@ -124,8 +126,9 @@ describe("handleToolExecutionStart read path checks", () => {
124126
await pending;
125127

126128
expect(ctx.state.toolMetaById.has("tool-await-flush")).toBe(true);
127-
expect(ctx.state.itemStartedCount).toBe(1);
129+
expect(ctx.state.itemStartedCount).toBe(2);
128130
expect(ctx.state.itemActiveIds.has("tool:tool-await-flush")).toBe(true);
131+
expect(ctx.state.itemActiveIds.has("command:tool-await-flush")).toBe(true);
129132
});
130133
});
131134

@@ -482,6 +485,157 @@ describe("handleToolExecutionEnd exec approval prompts", () => {
482485

483486
expect(ctx.state.deterministicApprovalPromptSent).toBe(false);
484487
});
488+
489+
it("emits approval + blocked command item events when exec needs approval", async () => {
490+
const { ctx, onAgentEvent } = createTestContext();
491+
492+
await handleToolExecutionStart(
493+
ctx as never,
494+
{
495+
type: "tool_execution_start",
496+
toolName: "exec",
497+
toolCallId: "tool-exec-approval-events",
498+
args: { command: "npm test" },
499+
} as never,
500+
);
501+
502+
await handleToolExecutionEnd(
503+
ctx as never,
504+
{
505+
type: "tool_execution_end",
506+
toolName: "exec",
507+
toolCallId: "tool-exec-approval-events",
508+
isError: false,
509+
result: {
510+
details: {
511+
status: "approval-pending",
512+
approvalId: "12345678-1234-1234-1234-123456789012",
513+
approvalSlug: "12345678",
514+
host: "gateway",
515+
command: "npm test",
516+
},
517+
},
518+
} as never,
519+
);
520+
521+
expect(onAgentEvent).toHaveBeenCalledWith(
522+
expect.objectContaining({
523+
stream: "approval",
524+
data: expect.objectContaining({
525+
phase: "requested",
526+
status: "pending",
527+
itemId: "command:tool-exec-approval-events",
528+
approvalId: "12345678-1234-1234-1234-123456789012",
529+
approvalSlug: "12345678",
530+
}),
531+
}),
532+
);
533+
expect(onAgentEvent).toHaveBeenCalledWith(
534+
expect.objectContaining({
535+
stream: "item",
536+
data: expect.objectContaining({
537+
itemId: "command:tool-exec-approval-events",
538+
phase: "end",
539+
status: "blocked",
540+
summary: "Awaiting approval before command can run.",
541+
}),
542+
}),
543+
);
544+
});
545+
});
546+
547+
describe("handleToolExecutionEnd derived tool events", () => {
548+
it("emits command output events for exec results", async () => {
549+
const { ctx, onAgentEvent } = createTestContext();
550+
551+
await handleToolExecutionStart(
552+
ctx as never,
553+
{
554+
type: "tool_execution_start",
555+
toolName: "exec",
556+
toolCallId: "tool-exec-output",
557+
args: { command: "ls" },
558+
} as never,
559+
);
560+
561+
await handleToolExecutionEnd(
562+
ctx as never,
563+
{
564+
type: "tool_execution_end",
565+
toolName: "exec",
566+
toolCallId: "tool-exec-output",
567+
isError: false,
568+
result: {
569+
details: {
570+
status: "completed",
571+
aggregated: "README.md",
572+
exitCode: 0,
573+
durationMs: 10,
574+
cwd: "/tmp/work",
575+
},
576+
},
577+
} as never,
578+
);
579+
580+
expect(onAgentEvent).toHaveBeenCalledWith(
581+
expect.objectContaining({
582+
stream: "command_output",
583+
data: expect.objectContaining({
584+
itemId: "command:tool-exec-output",
585+
phase: "end",
586+
output: "README.md",
587+
exitCode: 0,
588+
cwd: "/tmp/work",
589+
}),
590+
}),
591+
);
592+
});
593+
594+
it("emits patch summary events for apply_patch results", async () => {
595+
const { ctx, onAgentEvent } = createTestContext();
596+
597+
await handleToolExecutionStart(
598+
ctx as never,
599+
{
600+
type: "tool_execution_start",
601+
toolName: "apply_patch",
602+
toolCallId: "tool-patch-summary",
603+
args: { patch: "*** Begin Patch" },
604+
} as never,
605+
);
606+
607+
await handleToolExecutionEnd(
608+
ctx as never,
609+
{
610+
type: "tool_execution_end",
611+
toolName: "apply_patch",
612+
toolCallId: "tool-patch-summary",
613+
isError: false,
614+
result: {
615+
details: {
616+
summary: {
617+
added: ["a.ts"],
618+
modified: ["b.ts"],
619+
deleted: ["c.ts"],
620+
},
621+
},
622+
},
623+
} as never,
624+
);
625+
626+
expect(onAgentEvent).toHaveBeenCalledWith(
627+
expect.objectContaining({
628+
stream: "patch",
629+
data: expect.objectContaining({
630+
itemId: "patch:tool-patch-summary",
631+
added: ["a.ts"],
632+
modified: ["b.ts"],
633+
deleted: ["c.ts"],
634+
summary: "1 added, 1 modified, 1 deleted",
635+
}),
636+
}),
637+
);
638+
});
485639
});
486640

487641
describe("messaging tool media URL tracking", () => {

0 commit comments

Comments
 (0)