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
8 changes: 6 additions & 2 deletions agents/myra/src/definition.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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();
});
15 changes: 5 additions & 10 deletions agents/myra/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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 === "") {
Expand All @@ -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 },
Expand All @@ -77,7 +73,6 @@ export function buildMyraWorkflow(input: MyraWorkflowInput): WorkflowDefinition
inference: { sources: input.inferencePreferences },
toolPackagePins: [],
} satisfies AgentDefinition,
timeout: input.turnTimeoutMs,
triggers: "unbounded",
}),
},
Expand Down
67 changes: 67 additions & 0 deletions apps/web/src/chat/approval-row.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<li className="room-info-approval-row">
<div>
<span className="room-info-cell-primary">{item.headline}</span>
<br />
<span className="room-info-cell-context">
{item.agentName} · {formatRelativeTime(item.createdAt)}
</span>
</div>
<div className="room-info-row-actions">
<Button
variant="ghost"
size="sm"
disabled={pending !== null}
onClick={() => resolveMutation.mutate("deny")}
>
{pending === "deny" ? "Denying…" : "Deny"}
</Button>
<Button
variant="primary"
size="sm"
disabled={pending !== null}
onClick={() => resolveMutation.mutate("approve")}
>
{pending === "approve" ? "Approving…" : "Approve"}
</Button>
</div>
</li>
);
}
4 changes: 0 additions & 4 deletions apps/web/src/myra-deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -121,7 +119,6 @@ export function buildMyraDefinitionJson(
toolPackagePins: [],
},
drainBehavior: "wait",
timeout: ASSISTANT_TURN_TIMEOUT_MS,
triggers: "unbounded",
input: { from: "trigger.payload" },
},
Expand Down Expand Up @@ -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)),
Expand Down
24 changes: 23 additions & 1 deletion apps/web/src/pages/chat-thread-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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);
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -190,6 +205,13 @@ function ChatTranscript({
</div>
))}
</div>
{approvals.length === 0 ? null : (
<ul className="room-info-approval-list" aria-label={`${chat.agentName} is asking`}>
{approvals.map((item) => (
<ApprovalRow key={item.id} item={item} tenantId={tenantId} />
))}
</ul>
)}
{reply.error === null ? null : <p className="chat-thread-error">{errorText(reply.error)}</p>}
<Composer
placeholder={
Expand Down
63 changes: 3 additions & 60 deletions apps/web/src/pages/workbench-room-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { WarningCircle } from "@/lib/icons";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useRef, useState } from "react";

import { ApprovalRow } from "@/chat/approval-row";
import { IdentityAvatar } from "@/chat/avatar";
import { Composer } from "@/chat/composer";
import { Markdown } from "@/chat/markdown";
Expand All @@ -31,12 +32,11 @@ import {
type RoomMessage,
type RoomParticipant,
} from "@/chat/threads-api";
import { approveApproval, ArtifactListPageSchema, rejectApproval, useAPIQuery } from "../api";
import { ArtifactListPageSchema, useAPIQuery } from "../api";
import { useBench } from "../bench-context";
import { createFetchStockHub } from "../needs-converge";
import { usePendingApprovals, type PendingApproval } from "../pending-approvals";
import { usePendingApprovals } from "../pending-approvals";
import { roomKeys } from "../chat-path";
import { tenantKeys } from "../query-client";
import { StageTopBar } from "../shell/stage-top-bar";
import { redeployRoomAgent } from "../workbench-create";
import { workbenchIdFromPath } from "../workbench-path";
Expand Down Expand Up @@ -100,63 +100,6 @@ function ParticipantList({ participants }: { readonly participants: readonly Roo
);
}

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 (
<li className="room-info-approval-row">
<div>
<span className="room-info-cell-primary">{item.headline}</span>
<br />
<span className="room-info-cell-context">
{item.agentName} · {formatRelativeTime(item.createdAt)}
</span>
</div>
<div className="room-info-row-actions">
<Button
variant="ghost"
size="sm"
disabled={pending !== null}
onClick={() => resolveMutation.mutate("deny")}
>
{pending === "deny" ? "Denying…" : "Deny"}
</Button>
<Button
variant="primary"
size="sm"
disabled={pending !== null}
onClick={() => resolveMutation.mutate("approve")}
>
{pending === "approve" ? "Approving…" : "Approve"}
</Button>
</div>
</li>
);
}

/** 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. */
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/pending-approvals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
Expand Down Expand Up @@ -69,6 +72,7 @@ async function fetchAgentName(tenantId: string, runId: string): Promise<string>
function composeApproval(row: ApprovalRow, agentName: string): ApprovalDisplay {
return {
id: row.id,
agentAddress: row.agentAddress,
agentName,
headline: headlineFor(row.toolDefinition, row.toolArguments),
arguments: row.toolArguments,
Expand Down
Loading