Skip to content

Commit c5bacc4

Browse files
committed
Honor ActionDescription.awaitDecision in the agent loop
1 parent 702a088 commit c5bacc4

3 files changed

Lines changed: 205 additions & 80 deletions

File tree

packages/workshop-backend/src/agent.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,8 @@ export interface AgentHooks {
9090
capsules?: CapsuleEntry[], onOutputText?: (delta: string) => void): Promise<string>;
9191
activeAgentCallbackCount(chatId: number): number;
9292
rejectAllAgentCallbacks(chatId: number, error: string): void;
93-
consumeCapturedActions(chatId: number): {actions: number[], accessedGadget: boolean} | undefined;
93+
consumeCapturedActions(chatId: number)
94+
: {actions: number[], accessedGadget: boolean, awaitDecision: boolean} | undefined;
9495
addChatMessages(chatId: number, author: AiChatAuthorInfo, msgs: AiChatMessageBody[],
9596
totalTokens?: number, aiGatewayLogId?: string): void;
9697
emitChatStreamEvent(chatId: number, event: AiChatStreamEvent): void;
@@ -1326,6 +1327,10 @@ export async function runAgent(
13261327
// turn ending (which would strand it, since there'd be no card to accept/deny and thus no resume).
13271328
let connectionRequested = false;
13281329

1330+
// Latched by onStepFinish when this step submitted an awaitDecision action. stopWhen reads it
1331+
// after onStepFinish to end the turn until approval resumes it.
1332+
let awaitingActionDecision = false;
1333+
13291334
let flushCapturedYdocChanges = () => {
13301335
if (capturedYdocChanges.length === 0) {
13311336
return;
@@ -1938,6 +1943,8 @@ export async function runAgent(
19381943
// deny just leaves the turn ended.) A rejected requestConnection (e.g. unresolvable resource)
19391944
// leaves this false so the agent can fix the request and retry in the same turn.
19401945
() => connectionRequested,
1946+
// Wait for approval before continuing against state that may not reflect the action.
1947+
() => awaitingActionDecision,
19411948
// Auto-terminate when callback-initiated and all callbacks have been resolved/rejected.
19421949
...(callbackInitiated ? [() => hooks.activeAgentCallbackCount(chatId) === 0] : []),
19431950
],
@@ -1985,6 +1992,9 @@ export async function runAgent(
19851992
if (capturedActions.accessedGadget) {
19861993
msgs.push({type: "useGadget"});
19871994
}
1995+
if (capturedActions.awaitDecision) {
1996+
awaitingActionDecision = true;
1997+
}
19881998
}
19891999

19902000
// Append any connection requests the agent made this step, after the assistant message that

packages/workshop-backend/src/overseer.ts

Lines changed: 73 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1446,9 +1446,10 @@ class OverseerImpl implements AgentHooks {
14461446
}
14471447
}
14481448

1449-
// Maps chat ID to action numbers that were recently performed by that chat's agent. These are
1450-
// added to the chat log after the tool call returns.
1451-
#capturedActions = new Map<number, {actions: number[], accessedGadget: boolean}>();
1449+
// Maps chat ID to action numbers recently performed by that chat's agent. These are drained into
1450+
// the chat log after the tool returns. `awaitDecision` is true if any captured action needs it.
1451+
#capturedActions = new Map<number, {actions: number[], accessedGadget: boolean,
1452+
awaitDecision: boolean}>();
14521453

14531454
// Maps chat ID to connectionRequest message bodies created by that chat's agent during the
14541455
// current step. Spliced into the chat log after the tool call returns (see
@@ -1458,7 +1459,7 @@ class OverseerImpl implements AgentHooks {
14581459
#getOrCreateCapturedActions(chatId: number) {
14591460
let result = this.#capturedActions.get(chatId);
14601461
if (!result) {
1461-
result = {actions: [], accessedGadget: false};
1462+
result = {actions: [], accessedGadget: false, awaitDecision: false};
14621463
this.#capturedActions.set(chatId, result);
14631464
}
14641465
return result;
@@ -1687,13 +1688,18 @@ class OverseerImpl implements AgentHooks {
16871688
this.storage.actions.put(record);
16881689
this.#associateAction(caller, actionId);
16891690

1690-
// If this action is auto-approvable and the user has opted in to auto-approving its action kind
1691-
// on this gatekeeper, schedule a drain to apply it (and any earlier auto-eligible pending actions)
1692-
// after submitAction returns. We defer via waitUntil rather than applying inline because
1693-
// applying calls back into the gatekeeper facet, which is still awaiting this submitAction --
1694-
// doing so inline risks a re-entrancy stall.
1695-
if (description.autoApprovable && description.actionKind &&
1696-
this.storage.autoApproveTags.get(`${gatekeeperId}:${description.actionKind.tag}`) !== undefined) {
1691+
// Same auto-approval gate as before, named because awaitDecision uses it too. The drain is
1692+
// deferred because applying calls back into the gatekeeper facet still awaiting submitAction.
1693+
let willAutoApprove = !!(description.autoApprovable && description.actionKind &&
1694+
this.storage.autoApproveTags.get(`${gatekeeperId}:${description.actionKind.tag}`) !== undefined);
1695+
1696+
// Only agent turns suspend on awaitDecision, and only when a manual decision is pending.
1697+
// Auto-approved actions keep the seamless behavior the user opted into.
1698+
if (caller.from === "agent" && description.awaitDecision && !willAutoApprove) {
1699+
this.#getOrCreateCapturedActions(caller.chatId).awaitDecision = true;
1700+
}
1701+
1702+
if (willAutoApprove) {
16971703
this.ctx.waitUntil(this.drainAutoApprovals(gatekeeperId));
16981704
}
16991705
}
@@ -3054,7 +3060,8 @@ class OverseerImpl implements AgentHooks {
30543060
}
30553061
}
30563062

3057-
consumeCapturedActions(chatId: number): {actions: number[], accessedGadget: boolean} | undefined {
3063+
consumeCapturedActions(chatId: number)
3064+
: {actions: number[], accessedGadget: boolean, awaitDecision: boolean} | undefined {
30583065
let result = this.#capturedActions.get(chatId);
30593066
this.#capturedActions.delete(chatId);
30603067
return result;
@@ -4321,6 +4328,12 @@ class OverseerClientInterface extends RpcTarget implements Overseer {
43214328
let profile = await this.#getClientProfile();
43224329
await this.impl.applyPendingAction(action, profile, false);
43234330

4331+
// If this was an awaited agent action, resume only after all awaited actions in the turn are
4332+
// approved. If applyPendingAction throws, the action stays pending and the turn stays suspended.
4333+
if (action.caller.from === "agent" && action.description.awaitDecision) {
4334+
await this.#maybeResumeAfterActionDecision(action.caller.chatId);
4335+
}
4336+
43244337
// Clearing this manual gate may unblock later auto-eligible pending actions on the same
43254338
// gatekeeper, so cascade a drain (in-order) once this one is applied.
43264339
this.impl.ctx.waitUntil(this.impl.drainAutoApprovals(action.gatekeeperId));
@@ -4402,6 +4415,47 @@ class OverseerClientInterface extends RpcTarget implements Overseer {
44024415
}
44034416
}
44044417

4418+
// Resume a turn suspended on awaitDecision once all awaited actions from that turn are approved.
4419+
// Scoping to the current turn prevents older rejected actions from blocking future resumes.
4420+
async #maybeResumeAfterActionDecision(chatId: number): Promise<void> {
4421+
let awaited: (ActionRecord & {type: "action"})[] = [];
4422+
for (let msg of this.impl.storage.chats.list(
4423+
{prefix: `${keyString(chatId)}.`, reverse: true})) {
4424+
// Stop at whatever started the current turn: a user/gadget message or a gadget callback.
4425+
// (agentNudge is mid-turn, so it isn't a boundary.)
4426+
if (msg.type === "agentCallback") break;
4427+
if (msg.type === "message" &&
4428+
(msg.author.type === "user" || msg.author.type === "gadget")) {
4429+
break;
4430+
}
4431+
if (msg.type === "action") {
4432+
let record = this.impl.storage.actions.get(msg.actionId);
4433+
if (record && record.type === "action" &&
4434+
record.caller.from === "agent" && record.description.awaitDecision) {
4435+
awaited.push(record);
4436+
}
4437+
}
4438+
}
4439+
awaited.reverse(); // Present titles chronologically.
4440+
4441+
// Only resume when every awaited action in the turn has been decided and all were approved.
4442+
if (awaited.length === 0) return; // No awaited action in current turn.
4443+
if (awaited.some(r => r.state === "pending")) return; // Still waiting on a decision.
4444+
if (awaited.some(r => r.state === "rejected")) return; // Denial leaves the turn ended.
4445+
4446+
// Persist one note for replay; raw action cards are not surfaced to the LLM. Concurrent
4447+
// approvals could both pass the gate above and append duplicate notes (the DO input gate is
4448+
// open across these awaits), but that's cosmetic — #resumeSuspendedAgent still starts one turn.
4449+
let titleList = awaited.map(r => `"${r.description.title}"`).join(", ");
4450+
let summary =
4451+
`The changes you submitted have been approved and applied: ${titleList}. ` +
4452+
`Reads now reflect them.`;
4453+
let author = await this.#getClientProfile();
4454+
this.impl.addChatMessages(chatId, author, [{type: "message", message: summary}]);
4455+
4456+
await this.#resumeSuspendedAgent(chatId);
4457+
}
4458+
44054459
async rejectAction(id: number): Promise<void> {
44064460
let action = this.impl.storage.actions.get(id);
44074461
if (!action) {
@@ -4428,6 +4482,9 @@ class OverseerClientInterface extends RpcTarget implements Overseer {
44284482
action.appliedAt = new Date();
44294483
action.resolvedBy = profile;
44304484
this.impl.storage.actions.put(action);
4485+
4486+
// Deny leaves the turn ended, like denyConnectionRequest. The rejected record also prevents a
4487+
// sibling approval from resuming this turn.
44314488
}
44324489

44334490
// Enable auto-approval of actions carrying `actionKind` on the gatekeeper identified by
@@ -4516,11 +4573,9 @@ class OverseerClientInterface extends RpcTarget implements Overseer {
45164573
throw new Error(`No such connection request: ${requestId}`);
45174574
}
45184575

4519-
// Resume the agent on the given chat so it can react to an accepted connection request (only
4520-
// accept resumes; deny leaves the turn ended). Modeled on sendChatMessage: the accepted
4521-
// connectionRequest message itself (read from history) supplies the outcome the agent sees, so we
4522-
// don't append a separate user message here.
4523-
async #resumeAgentAfterConnection(chatId: number): Promise<void> {
4576+
// Restart a suspended agent turn after its outcome is recorded in chat history (accepted
4577+
// connection, or all awaited actions approved). Denials intentionally don't call this.
4578+
async #resumeSuspendedAgent(chatId: number): Promise<void> {
45244579
let meta = this.impl.storage.chatMeta.get(chatId);
45254580
if (!meta) return; // Chat deleted.
45264581
if (meta.activeAgent) return; // Already running; it'll pick up the change on its next read.
@@ -4567,7 +4622,7 @@ class OverseerClientInterface extends RpcTarget implements Overseer {
45674622
msg.timestamp = this.impl.getChatTimestamp();
45684623
this.impl.storage.chats.put(msg); // fires the subscriber update() → re-delivers the card
45694624

4570-
await this.#resumeAgentAfterConnection(msg.chatId);
4625+
await this.#resumeSuspendedAgent(msg.chatId);
45714626
}
45724627

45734628
async denyConnectionRequest(requestId: string): Promise<void> {

0 commit comments

Comments
 (0)