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/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 (
+
- );
-}
-
/** 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,