From 4c71ced24588abe46b99fb5c52238c4a759acfc7 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 18 Sep 2026 00:21:37 -0700 Subject: [PATCH 1/2] fix(myra): drop the step timeout so a parked approval no longer aborts the run (CL-8493) --- agents/myra/src/definition.test.ts | 8 ++++++-- agents/myra/src/index.ts | 15 +++++---------- apps/web/src/myra-deploy.ts | 4 ---- 3 files changed, 11 insertions(+), 16 deletions(-) diff --git a/agents/myra/src/definition.test.ts b/agents/myra/src/definition.test.ts index c74cd89144..4c0a5e9845 100644 --- a/agents/myra/src/definition.test.ts +++ b/agents/myra/src/definition.test.ts @@ -10,7 +10,6 @@ import { ASSISTANT_STEP_ID, buildMyraWorkflow } from "./index"; const INPUT = { triggerAddress: "ins_dep000000000000@example.test", inferencePreferences: [{ provider: "anthropic", model: "claude-test" }], - turnTimeoutMs: 600000, systemPrompt: "You are Myra.", } as const; @@ -40,5 +39,10 @@ test("the definition has exactly one step, so a deployment stays conversational" const definition = buildMyraWorkflow(INPUT); expect(definition.stepOrder).toEqual([ASSISTANT_STEP_ID]); expect(assistantStep(definition).triggers).toBe("unbounded"); - expect(assistantStep(definition).timeout).toBe(INPUT.turnTimeoutMs); +}); + +test("the step carries no timeout, so an approval park never aborts the run", () => { + // A step timeout stays armed across an approval park, so any finite + // value aborts a warm agent waiting on a person to answer an ask gate. + expect(assistantStep(buildMyraWorkflow(INPUT)).timeout).toBeUndefined(); }); diff --git a/agents/myra/src/index.ts b/agents/myra/src/index.ts index 34d8fa61b4..ef484d2ba4 100644 --- a/agents/myra/src/index.ts +++ b/agents/myra/src/index.ts @@ -36,8 +36,6 @@ export interface MyraWorkflowInput { readonly triggerAddress: string; /** Provider/model preferences, in order; resolved at deploy time. */ readonly inferencePreferences: readonly InferencePreference[]; - /** Per-turn timeout in milliseconds, enforced on the single step. */ - readonly turnTimeoutMs: number; /** The prompt this deployment runs with. */ readonly systemPrompt: string; } @@ -48,10 +46,11 @@ export interface MyraWorkflowInput { * one warm agent with durable memory across runs). A second step would * silently trade that memory away, so the step count is contract, not style. * - * The step always sets an explicit `timeout` — the singular `agent:` - * shorthand sets none, and a wedged inference call would then hang a run - * forever. `toolPackagePins` is empty because the source lineage resolves - * no manifest: the factories above are the whole tool surface. + * The step carries no `timeout`. A step timeout is an execution budget that + * stays armed across an approval park, so any finite value aborts a warm + * agent that is waiting on a person to answer an ask-gated tool call. + * `toolPackagePins` is empty because the source lineage resolves no + * manifest: the factories above are the whole tool surface. */ export function buildMyraWorkflow(input: MyraWorkflowInput): WorkflowDefinition { if (input.triggerAddress === "") { @@ -60,9 +59,6 @@ export function buildMyraWorkflow(input: MyraWorkflowInput): WorkflowDefinition if (input.systemPrompt === "") { throw new Error("buildMyraWorkflow requires a non-empty systemPrompt"); } - if (!Number.isInteger(input.turnTimeoutMs) || input.turnTimeoutMs <= 0) { - throw new Error("buildMyraWorkflow requires turnTimeoutMs to be a positive integer"); - } return defineWorkflow({ id: ASSISTANT_WORKFLOW_ID, trigger: { type: "mail", to: input.triggerAddress }, @@ -77,7 +73,6 @@ export function buildMyraWorkflow(input: MyraWorkflowInput): WorkflowDefinition inference: { sources: input.inferencePreferences }, toolPackagePins: [], } satisfies AgentDefinition, - timeout: input.turnTimeoutMs, triggers: "unbounded", }), }, diff --git a/apps/web/src/myra-deploy.ts b/apps/web/src/myra-deploy.ts index 713a0b6c5c..fc05e958a8 100644 --- a/apps/web/src/myra-deploy.ts +++ b/apps/web/src/myra-deploy.ts @@ -75,8 +75,6 @@ export async function ensureMyraSourceAsset( return existing.id; } -const ASSISTANT_TURN_TIMEOUT_MS = 2 * 60 * 1000; - /** * The function-free projection of what `@corbits/myra`'s `buildMyraWorkflow` * produces, written to the asset's `definition.json` for readers — the entry @@ -121,7 +119,6 @@ export function buildMyraDefinitionJson( toolPackagePins: [], }, drainBehavior: "wait", - timeout: ASSISTANT_TURN_TIMEOUT_MS, triggers: "unbounded", input: { from: "trigger.payload" }, }, @@ -189,7 +186,6 @@ export async function pushMyraSource( buildInput: { triggerAddress, inferencePreferences: declaredSources.map((source) => ({ ...source })), - turnTimeoutMs: ASSISTANT_TURN_TIMEOUT_MS, systemPrompt: ASSISTANT_SYSTEM_PROMPT, }, workflowJson: JSON.stringify(buildMyraDefinitionJson(triggerAddress, declaredSources)), From cebed38331d7e2d06c80b34d9f9b584d22b5d950 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 18 Sep 2026 00:21:37 -0700 Subject: [PATCH 2/2] feat(web): answer an agent's pending approvals from the chat transcript (CL-8493) --- apps/web/src/chat/approval-row.tsx | 67 ++++++++++++++++++++++ apps/web/src/pages/chat-thread-page.tsx | 24 +++++++- apps/web/src/pages/workbench-room-page.tsx | 63 +------------------- apps/web/src/pending-approvals.ts | 4 ++ 4 files changed, 97 insertions(+), 61 deletions(-) create mode 100644 apps/web/src/chat/approval-row.tsx diff --git a/apps/web/src/chat/approval-row.tsx b/apps/web/src/chat/approval-row.tsx new file mode 100644 index 0000000000..e20a6c46e2 --- /dev/null +++ b/apps/web/src/chat/approval-row.tsx @@ -0,0 +1,67 @@ +// One pending approval with its approve/deny buttons. Shared by the +// workbench room's info column and the chat transcript, so a person can +// answer an agent's ask wherever they are looking when it lands. + +import { Button, formatRelativeTime, toast } from "@corbits/react-ui"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { approveApproval, rejectApproval } from "../api"; +import type { PendingApproval } from "../pending-approvals"; +import { tenantKeys } from "../query-client"; + +export function ApprovalRow({ + item, + tenantId, +}: { + readonly item: PendingApproval; + readonly tenantId: string; +}) { + const queryClient = useQueryClient(); + const resolveMutation = useMutation({ + mutationFn: (action: "approve" | "deny") => + action === "approve" ? approveApproval(tenantId, item.id) : rejectApproval(tenantId, item.id), + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: tenantKeys.pendingApprovals(tenantId), + }); + }, + onError: (cause, action) => { + toast( + cause instanceof Error + ? cause.message + : `Couldn't ${action === "approve" ? "approve" : "deny"} that request.`, + ); + }, + }); + const pending = resolveMutation.isPending ? resolveMutation.variables : null; + + return ( +
  • +
    + {item.headline} +
    + + {item.agentName} · {formatRelativeTime(item.createdAt)} + +
    +
    + + +
    +
  • + ); +} diff --git a/apps/web/src/pages/chat-thread-page.tsx b/apps/web/src/pages/chat-thread-page.tsx index 6b5a3f4879..6ba681f3f0 100644 --- a/apps/web/src/pages/chat-thread-page.tsx +++ b/apps/web/src/pages/chat-thread-page.tsx @@ -7,6 +7,7 @@ import { WarningCircle } from "@/lib/icons"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useEffect, useMemo, useState } from "react"; +import { ApprovalRow } from "@/chat/approval-row"; import { IdentityAvatar } from "@/chat/avatar"; import { Composer } from "@/chat/composer"; import { Markdown } from "@/chat/markdown"; @@ -23,6 +24,8 @@ import { import { MYRA_SOURCE_CONFIG } from "../myra-source"; import { useBench } from "../bench-context"; import { chatIdFromPath, chatKeys, chatPath, NEW_CHAT_PATH } from "../chat-path"; +import { usePendingApprovals } from "../pending-approvals"; +import { tenantKeys } from "../query-client"; function errorText(cause: unknown): string { return cause instanceof Error ? cause.message : String(cause); @@ -136,15 +139,27 @@ function ChatTranscript({ if (chat !== undefined) markChatSeen(chatId, chat.lastMessageId); // The inbox stream is the only signal that an agent has answered; it - // carries no chat identity, so it invalidates rather than patches. + // carries no chat identity, so it invalidates rather than patches. An + // agent that parks on an ask sends no mail, but the same stream ticks + // over its turn, so the approvals read is refreshed alongside. useEffect( () => subscribeToInbox(tenantId, () => { void queryClient.invalidateQueries({ queryKey: chatKeys.scope(tenantId) }); + void queryClient.invalidateQueries({ queryKey: tenantKeys.pendingApprovals(tenantId) }); }), [tenantId, queryClient], ); + // Only this agent's asks belong in this transcript; the bench-wide list + // is filtered down to the run address this chat talks to. + const approvalsQuery = usePendingApprovals(tenantId); + const liveAddress = chat?.agent.liveAddress ?? null; + const approvals = + approvalsQuery.kind === "ready" && liveAddress !== null + ? approvalsQuery.data.filter((item) => item.agentAddress === liveAddress) + : []; + const reply = useMutation({ mutationFn: (text: string) => { if (chat === undefined) throw new Error("no chat to reply in"); @@ -190,6 +205,13 @@ function ChatTranscript({ ))} + {approvals.length === 0 ? null : ( + + )} {reply.error === null ? null :

    {errorText(reply.error)}

    } - action === "approve" ? approveApproval(tenantId, item.id) : rejectApproval(tenantId, item.id), - onSuccess: () => { - void queryClient.invalidateQueries({ - queryKey: tenantKeys.pendingApprovals(tenantId), - }); - }, - onError: (cause, action) => { - toast( - cause instanceof Error - ? cause.message - : `Couldn't ${action === "approve" ? "approve" : "deny"} that request.`, - ); - }, - }); - const pending = resolveMutation.isPending ? resolveMutation.variables : null; - - return ( -
  • -
    - {item.headline} -
    - - {item.agentName} · {formatRelativeTime(item.createdAt)} - -
    -
    - - -
    -
  • - ); -} - /** The left info column: what Mission Control used to show, scoped to this * one workbench — its latest activity, its relevant artifacts, and what's * pending on it — plus the participant roster already read for the room. */ diff --git a/apps/web/src/pending-approvals.ts b/apps/web/src/pending-approvals.ts index 986b433e14..e8aa07d751 100644 --- a/apps/web/src/pending-approvals.ts +++ b/apps/web/src/pending-approvals.ts @@ -22,6 +22,9 @@ import { tenantKeys } from "./query-client"; * field on it holds an identifier a person would have to decode. */ export type ApprovalDisplay = { readonly id: string; + /** The live run address of the agent that is asking. The chat page filters + * on it to show only the asks its own agent raised. */ + readonly agentAddress: string; readonly agentName: string; readonly headline: string; readonly arguments: Record; @@ -69,6 +72,7 @@ async function fetchAgentName(tenantId: string, runId: string): Promise function composeApproval(row: ApprovalRow, agentName: string): ApprovalDisplay { return { id: row.id, + agentAddress: row.agentAddress, agentName, headline: headlineFor(row.toolDefinition, row.toolArguments), arguments: row.toolArguments,