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
22 changes: 10 additions & 12 deletions packages/junior-evals/src/behavior-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ import { upsertTurnRecord } from "@/chat/task-execution/turn-cursor";
import { turnCursorKey } from "@/chat/task-execution/turn-cursor-keys";
import { resetSkillDiscoveryCache } from "@/chat/skills";
import { juniorToolOutputSchema } from "@/chat/tool-support/structured-result";
import { annotateTurnDeadlineToolResult } from "@/chat/tool-support/turn-deadline-result";
import { projectTimedOutToolResult } from "@/chat/tool-support/timed-out-tool-result";
import { DEFAULT_MAX_CHARS, MAX_FETCH_CHARS } from "@/chat/tools/web/constants";
import { truncateWebFetchContent } from "@/chat/tools/web/fetch-content";
import { createWebFetchTool } from "@/chat/tools/web/fetch-tool";
Expand Down Expand Up @@ -1822,19 +1822,17 @@ function buildRuntimeServices(
await runRequest.durability?.onInputCommitted?.();
const nowMs = Date.now();
const toolCallId = "eval-timeout-resume-tool-call";
const unknownOutcome = {
const abortedAttempt = {
target: timeoutResume.tool_name,
aborted: true,
message: "Command outcome was not confirmed.",
};
const deadlineResult = annotateTurnDeadlineToolResult({
content: [{ type: "text", text: JSON.stringify(unknownOutcome) }],
details: unknownOutcome,
const timedOutResult = projectTimedOutToolResult({
content: [{ type: "text", text: JSON.stringify(abortedAttempt) }],
details: abortedAttempt,
});
if (
!deadlineResult?.content ||
deadlineResult.details === undefined ||
deadlineResult.isError !== true
!timedOutResult?.content ||
timedOutResult.details === undefined
) {
throw new Error("Failed to build timeout continuation fixture");
}
Expand Down Expand Up @@ -1865,9 +1863,9 @@ function buildRuntimeServices(
role: "toolResult",
toolCallId,
toolName: timeoutResume.tool_name,
content: deadlineResult.content,
details: deadlineResult.details,
isError: deadlineResult.isError,
content: timedOutResult.content,
details: timedOutResult.details,
isError: timedOutResult.isError,
timestamp: nowMs,
},
] as PiMessage[];
Expand Down
14 changes: 9 additions & 5 deletions packages/junior/src/chat/agent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ import {
import { nextProviderRetry } from "@/chat/services/provider-retry";
import { nextEmptyOutputContinuation } from "@/chat/services/empty-output-continuation";
import { getDiscardedRetryUsage } from "@/chat/agent/retry-usage";
import { annotateTurnDeadlineToolResult } from "@/chat/tool-support/turn-deadline-result";
import { projectTimedOutToolResult } from "@/chat/tool-support/timed-out-tool-result";
import {
configuredTurnRoute,
selectTurnRoute,
Expand Down Expand Up @@ -1098,16 +1098,20 @@ async function executeAgentRunInPrivacyContext(
return undefined;
},
afterToolCall: async ({ result, toolCall }, signal) => {
const deadlineResult =
// Host continuity is session-owned (`resumeReason: "timeout"` + auto
// continue). Only rewrite tool attempts that themselves aborted; a
// finished sibling must keep its real result. Project those preempted
// attempts onto the normal `timed_out` field — not cancelled jargon.
const timedOutResult =
runResume.timedOut && signal?.aborted
? annotateTurnDeadlineToolResult(result)
? projectTimedOutToolResult(result)
: undefined;
const sourceResult = deadlineResult ?? result;
const sourceResult = timedOutResult ?? result;
const projectedResult = wiring.projectActionReviewResult(
toolCall.id,
sourceResult,
);
return deadlineResult || projectedResult !== result
return timedOutResult || projectedResult !== result
? projectedResult
: undefined;
},
Expand Down
2 changes: 1 addition & 1 deletion packages/junior/src/chat/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -353,7 +353,7 @@ const EXECUTION_CONTRACT_RULES = [
"- Ask the user only for missing access, approval, or a decision that blocks safe progress. Ask one focused question; otherwise infer conservatively and continue.",
"- For conflicting evidence, compare sources and state which source is authoritative for the answer.",
"- Use `reportProgress` only for work with multiple substantive phases or a materially long wait. Skip short lookups and routine commands; after an initial update, call it again only when the major phase changes.",
"- When a tool outcome is unknown and may include side effects, inspect authoritative state before retrying. If state already reflects the intended result, do not repeat the mutation.",
"- A tool result with `timed_out: true` means that attempt did not finish. Continue the active task. Before retrying work that may have side effects, inspect authoritative state and do not repeat a mutation that already applied.",
];

const CONVERSATION_RULES = [
Expand Down
2 changes: 2 additions & 0 deletions packages/junior/src/chat/tool-support/structured-result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export const juniorToolOutputSchema = z
.object({
target: z.string().min(1).optional(),
truncated: z.boolean().optional(),
/** True when this attempt did not finish before its time budget. */
timed_out: z.boolean().optional(),
continuation: juniorToolContinuationSchema.optional(),
})
.passthrough();
Expand Down
43 changes: 43 additions & 0 deletions packages/junior/src/chat/tool-support/timed-out-tool-result.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import type {
AfterToolCallResult,
AgentToolResult,
} from "@earendil-works/pi-agent-core";
import { makeStructuredToolOutput } from "@/chat/tool-support/structured-result";

/**
* Project a host-preempted tool attempt onto the normal tool-result shape.
*
* Only rewrite results that mark the attempt itself as aborted. A finished
* sibling tool that settles after the host abort signal must keep its real
* outcome. Host continuity stays in session state (`resumeReason: "timeout"`)
* and automatic continuation. The model only needs the same fact bash already
* reports for command timeouts: this attempt timed out.
*/
export function projectTimedOutToolResult(
result: AgentToolResult<unknown>,
): AfterToolCallResult | undefined {
const details = result.details;
if (
!details ||
typeof details !== "object" ||
Array.isArray(details) ||
!("aborted" in details) ||
details.aborted !== true
) {
return undefined;
}
const record = details as Record<string, unknown>;
const target =
typeof record.target === "string" && record.target.length > 0
? record.target
: undefined;
const envelope = makeStructuredToolOutput({
...(target ? { target } : {}),
timed_out: true as const,
});
return {
content: envelope.content,
details: envelope.details,
isError: false,
};
}
39 changes: 0 additions & 39 deletions packages/junior/src/chat/tool-support/turn-deadline-result.ts

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,7 @@ vi.mock("@/chat/pi/traced-stream", () => ({

const timeoutContinuation =
!options?.signal?.aborted &&
JSON.stringify(context.messages ?? []).includes(
'"cause":"turn_deadline"',
);
JSON.stringify(context.messages ?? []).includes('"timed_out":true');
const message =
call === 1
? {
Expand Down Expand Up @@ -280,13 +278,17 @@ describe("tool timeout continuation composition", () => {
});
expect(suspendedRecord?.piMessages.at(-1)).toMatchObject({
role: "toolResult",
isError: true,
isError: false,
details: {
target: "run-the-targeted-cloudflare-test",
timed_out: true,
},
});
expect(JSON.stringify(suspendedRecord?.piMessages.at(-1))).toContain(
'"cause":"turn_deadline"',
const suspendedToolResult = JSON.stringify(
suspendedRecord?.piMessages.at(-1),
);
expect(JSON.stringify(suspendedRecord?.piMessages.at(-1))).toContain(
'"cause":"turn_deadline"',
expect(suspendedToolResult).not.toMatch(
/cancelled|turn_deadline|execution_slice|unconfirmed|deadline/i,
);

const resumed = await executeAgentRun(request);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import { projectTimedOutToolResult } from "@/chat/tool-support/timed-out-tool-result";

describe("projectTimedOutToolResult", () => {
it("projects host-preempted tools onto the native timed_out field", () => {
const result = projectTimedOutToolResult({
content: [
{
type: "text",
text: JSON.stringify({
target: "pnpm test",
aborted: true,
exit_code: 130,
stderr: "Command aborted because the agent turn was cancelled.",
}),
},
],
details: {
target: "pnpm test",
aborted: true,
exit_code: 130,
stderr: "Command aborted because the agent turn was cancelled.",
},
});

expect(result).toEqual({
content: [
{
type: "text",
text: JSON.stringify({
target: "pnpm test",
timed_out: true,
}),
},
],
details: {
target: "pnpm test",
timed_out: true,
},
isError: false,
});
expect(JSON.stringify(result)).not.toMatch(
/cancelled|turn_deadline|execution_slice|unconfirmed|outcome|deadline/i,
);
});

it("leaves finished tool results alone", () => {
expect(
projectTimedOutToolResult({
content: [
{
type: "text",
text: JSON.stringify({
target: "editFile",
ok: true,
}),
},
],
details: {
target: "editFile",
ok: true,
},
}),
).toBeUndefined();
});
});
Loading