@vellumai/assistant 0.12.0-staging.1 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Dockerfile +5 -0
- package/docs/architecture/turn-actor.md +9 -0
- package/knip.json +1 -0
- package/node_modules/@vellumai/app-icons/package.json +18 -0
- package/node_modules/@vellumai/app-icons/src/index.test.ts +85 -0
- package/node_modules/@vellumai/app-icons/src/index.ts +387 -0
- package/node_modules/@vellumai/app-icons/tsconfig.json +20 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +31 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/channels.ts +28 -3
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +31 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/channels.ts +28 -3
- package/node_modules/@vellumai/gateway-client/src/__tests__/plugin-admission-denied-contract.test.ts +10 -0
- package/node_modules/@vellumai/gateway-client/src/index.ts +1 -0
- package/node_modules/@vellumai/gateway-client/src/plugin-admission-denied-contract.ts +15 -2
- package/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +31 -0
- package/node_modules/@vellumai/service-contracts/src/channels.ts +28 -3
- package/openapi.yaml +628 -0
- package/package.json +3 -1
- package/scripts/smoke-container-workspace-dependencies.ts +19 -0
- package/src/__tests__/app-builder-icon-names.test.ts +29 -0
- package/src/__tests__/assistant-attachment-directive.test.ts +4 -0
- package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +1 -0
- package/src/__tests__/conversation-agent-loop-overflow.test.ts +1 -0
- package/src/__tests__/conversation-agent-loop.test.ts +2 -0
- package/src/__tests__/conversation-attachments.test.ts +142 -0
- package/src/__tests__/conversation-delete-activation-progress.test.ts +145 -0
- package/src/__tests__/conversation-event-sink.test.ts +50 -0
- package/src/__tests__/conversation-process-app-control-preactivation.test.ts +10 -2
- package/src/__tests__/conversation-queue.test.ts +297 -0
- package/src/__tests__/credential-routes.test.ts +185 -6
- package/src/__tests__/drain-kick-guard.test.ts +2 -0
- package/src/__tests__/drain-requeue-on-contention.test.ts +6 -0
- package/src/__tests__/messaging-send-tool.test.ts +42 -0
- package/src/__tests__/oauth-commands-routes.test.ts +47 -0
- package/src/__tests__/subagent-manager-notify.test.ts +25 -4
- package/src/activation/progress-store.test.ts +1564 -0
- package/src/activation/progress-store.ts +1296 -0
- package/src/activation/turn-hooks.test.ts +246 -0
- package/src/activation/turn-hooks.ts +126 -0
- package/src/api/events/subagent-status-changed.ts +7 -5
- package/src/api/responses/activation.ts +136 -0
- package/src/apps/app-store.ts +8 -0
- package/src/cli/__tests__/catalog-search-help.test.ts +7 -5
- package/src/cli/commands/__tests__/cli-test-harness.ts +12 -3
- package/src/cli/commands/channels/__tests__/channels.test.ts +2 -0
- package/src/cli/commands/channels/__tests__/request.test.ts +191 -0
- package/src/cli/commands/channels/index.help.ts +70 -18
- package/src/cli/commands/channels/index.ts +17 -6
- package/src/cli/commands/channels/request.ts +66 -0
- package/src/cli/commands/oauth/request.test.ts +2 -0
- package/src/cli/commands/oauth/request.ts +227 -186
- package/src/config/bundled-skills/app-builder/SKILL.md +3 -1
- package/src/config/bundled-skills/app-builder/TOOLS.json +2 -2
- package/src/config/bundled-skills/messaging/tools/messaging-send.ts +8 -4
- package/src/config/feature-flag-registry.json +35 -5
- package/src/daemon/assistant-attachments.ts +11 -0
- package/src/daemon/conversation-agent-loop-handlers.ts +5 -0
- package/src/daemon/conversation-agent-loop.ts +71 -3
- package/src/daemon/conversation-attachments.ts +42 -1
- package/src/daemon/conversation-event-sink.ts +25 -0
- package/src/daemon/conversation-process.ts +69 -21
- package/src/daemon/conversation-store.ts +4 -3
- package/src/daemon/conversation-surfaces.ts +19 -4
- package/src/daemon/message-types/sync.ts +2 -0
- package/src/ipc/assistant-server.ts +2 -0
- package/src/ipc/routes/__tests__/activation-sync-ipc-routes.test.ts +51 -0
- package/src/ipc/routes/activation-sync-ipc-routes.ts +42 -0
- package/src/notifications/AGENTS.md +1 -1
- package/src/notifications/__tests__/proactive-home-thread.test.ts +111 -0
- package/src/notifications/conversation-pairing.ts +47 -5
- package/src/notifications/delivered-post-record.ts +3 -0
- package/src/persistence/__tests__/slack-thread-root-evidence.test.ts +102 -0
- package/src/persistence/conversation-crud.ts +39 -0
- package/src/persistence/delivery-crud.ts +13 -0
- package/src/plugins/AGENTS.md +1 -0
- package/src/runtime/auth/__tests__/route-policy.test.ts +37 -0
- package/src/runtime/routes/__tests__/user-routes-notices.test.ts +248 -0
- package/src/runtime/routes/activation-routes.test.ts +415 -0
- package/src/runtime/routes/activation-routes.ts +172 -0
- package/src/runtime/routes/credential-routes.ts +46 -3
- package/src/runtime/routes/index.ts +2 -0
- package/src/runtime/routes/oauth-commands-routes.ts +21 -8
- package/src/runtime/routes/platform-managed-credentials.ts +48 -0
- package/src/runtime/routes/secret-routes.ts +3 -12
- package/src/runtime/routes/user-route-resolution.ts +21 -0
- package/src/runtime/routes/user-routes.ts +112 -9
- package/src/runtime/sync/activation-sidecar-publish.test.ts +78 -0
- package/src/runtime/sync/documents-sidecar-publish.test.ts +3 -0
- package/src/runtime/sync/resource-sync-events.ts +23 -0
- package/src/runtime/sync/worker-daemon-notify.test.ts +36 -0
- package/src/runtime/sync/worker-daemon-notify.ts +39 -1
- package/src/tools/apps/executors.ts +8 -8
|
@@ -10,6 +10,7 @@ import { SENTINEL_REDACTION_VERSION } from "@vellumai/service-contracts/redacted
|
|
|
10
10
|
import type pino from "pino";
|
|
11
11
|
import { v4 as uuid } from "uuid";
|
|
12
12
|
|
|
13
|
+
import { onActivationToolCall } from "../activation/turn-hooks.js";
|
|
13
14
|
import type { AgentEvent } from "../agent/loop.js";
|
|
14
15
|
import type { AnsweredQuestion } from "../api/events/question-answered.js";
|
|
15
16
|
import type { AssistantEvent } from "../api/index.js";
|
|
@@ -1760,6 +1761,10 @@ export function handleToolUse(
|
|
|
1760
1761
|
event: Extract<AgentEvent, { type: "tool_use" }>,
|
|
1761
1762
|
): void {
|
|
1762
1763
|
state.toolUseIdToName.set(event.id, event.name);
|
|
1764
|
+
// Activation checklist: keep the launched task's live step count moving.
|
|
1765
|
+
// Fire-and-forget and throttled inside the hook; a no-op for every
|
|
1766
|
+
// conversation no activation task points at.
|
|
1767
|
+
onActivationToolCall(deps.ctx.conversationId);
|
|
1763
1768
|
if (event.name === "app_create" || event.name === "app_refresh") {
|
|
1764
1769
|
state.appBuildToolUsedThisRun = true;
|
|
1765
1770
|
}
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { v4 as uuid } from "uuid";
|
|
11
11
|
|
|
12
|
+
import { onActivationTurnComplete } from "../activation/turn-hooks.js";
|
|
12
13
|
import { repairHistoryForRun } from "../agent/history-repair/history-repair.js";
|
|
13
14
|
import type {
|
|
14
15
|
AgentEvent,
|
|
@@ -78,6 +79,7 @@ import type { Provider } from "../providers/types.js";
|
|
|
78
79
|
import { resolveCapabilities } from "../runtime/capabilities.js";
|
|
79
80
|
import { resolveTurnReplyMessageId } from "../runtime/channel-reply-delivery.js";
|
|
80
81
|
import { isNoResponseOnlyText } from "../runtime/no-response.js";
|
|
82
|
+
import { getByConversation as getPendingInteractionsByConversation } from "../runtime/pending-interactions.js";
|
|
81
83
|
import { publishConversationMessagesChanged } from "../runtime/sync/resource-sync-events.js";
|
|
82
84
|
import { stampTurnOutcome } from "../telemetry/turn-outcome.js";
|
|
83
85
|
import {
|
|
@@ -107,6 +109,7 @@ import {
|
|
|
107
109
|
} from "./conversation-agent-loop-handlers.js";
|
|
108
110
|
import {
|
|
109
111
|
approveHostAttachmentRead,
|
|
112
|
+
type PersistedAttachmentFile,
|
|
110
113
|
resolveAssistantAttachments,
|
|
111
114
|
} from "./conversation-attachments.js";
|
|
112
115
|
import {
|
|
@@ -130,6 +133,7 @@ import {
|
|
|
130
133
|
} from "./conversation-runtime-assembly.js";
|
|
131
134
|
import type { CurrentTurnSurface } from "./conversation-surfaces.js";
|
|
132
135
|
import {
|
|
136
|
+
hasBlockingPendingSurface,
|
|
133
137
|
markSurfaceCompleted,
|
|
134
138
|
settleRunningTaskProgressSurfaces,
|
|
135
139
|
} from "./conversation-surfaces.js";
|
|
@@ -256,6 +260,35 @@ const FALLBACK_TURN_TRUST: TrustContext = {
|
|
|
256
260
|
*/
|
|
257
261
|
export type AssistantSurface = CurrentTurnSurface;
|
|
258
262
|
|
|
263
|
+
/**
|
|
264
|
+
* Interaction kinds that are a prompt posed to the user. The `host_*` kinds
|
|
265
|
+
* are in-flight tool executions proxied to a client, which are the loop
|
|
266
|
+
* waiting on a machine rather than on a person.
|
|
267
|
+
*/
|
|
268
|
+
const USER_PROMPT_INTERACTION_KINDS = new Set([
|
|
269
|
+
"confirmation",
|
|
270
|
+
"acp_confirmation",
|
|
271
|
+
"question",
|
|
272
|
+
"secret",
|
|
273
|
+
]);
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Whether this turn handed control back to the user instead of delivering.
|
|
277
|
+
*
|
|
278
|
+
* True when something the turn put on screen still needs an answer once the
|
|
279
|
+
* turn is over: a question or confirmation prompt that outlived it, or an
|
|
280
|
+
* interactive surface still awaiting an action. Both are structural, so a
|
|
281
|
+
* question the assistant asks in prose alone reads as a delivered turn.
|
|
282
|
+
*/
|
|
283
|
+
function turnEndedAwaitingUser(ctx: Conversation): boolean {
|
|
284
|
+
if (hasBlockingPendingSurface(ctx)) {
|
|
285
|
+
return true;
|
|
286
|
+
}
|
|
287
|
+
return getPendingInteractionsByConversation(ctx.conversationId).some(
|
|
288
|
+
(interaction) => USER_PROMPT_INTERACTION_KINDS.has(interaction.kind),
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
259
292
|
// ── abort watchdog ───────────────────────────────────────────────────
|
|
260
293
|
|
|
261
294
|
/**
|
|
@@ -819,6 +852,11 @@ export async function runAgentLoopImpl(
|
|
|
819
852
|
// provider-error turn's only assistant row is the synthetic error text, so
|
|
820
853
|
// the deferred tail must not treat either as a final reply.
|
|
821
854
|
let turnCompleted = false;
|
|
855
|
+
// Files this turn attached that survived resolution and persistence, in
|
|
856
|
+
// the shape the activation hook records as artifacts. Rejected directives
|
|
857
|
+
// never reach it, so a checklist card can never point at a file the turn
|
|
858
|
+
// failed to attach.
|
|
859
|
+
let persistedAttachmentFiles: readonly PersistedAttachmentFile[] = [];
|
|
822
860
|
// True once `releaseTurn` has run. The happy path releases as soon as the
|
|
823
861
|
// turn's content is settled; the `finally` calls it again as the backstop for
|
|
824
862
|
// the cancel/error paths that never reached the early release.
|
|
@@ -1202,11 +1240,15 @@ export async function runAgentLoopImpl(
|
|
|
1202
1240
|
|
|
1203
1241
|
// Unified `<turn_context>` actor input for this turn (model-facing grounding
|
|
1204
1242
|
// metadata; the conversation runtime context remains the source for policy
|
|
1205
|
-
// gating).
|
|
1206
|
-
//
|
|
1243
|
+
// gating). Derived from the turn's own actor, so the block describes who
|
|
1244
|
+
// is speaking now rather than who last touched the conversation. Resolved
|
|
1245
|
+
// once at turn start and frozen onto the conversation so the
|
|
1246
|
+
// post-compaction hook re-emits this same value during in-loop recovery
|
|
1207
1247
|
// instead of re-resolving against contact/member registry state that may
|
|
1208
1248
|
// have drifted mid-turn.
|
|
1209
|
-
const actorContext = resolveTurnInboundActorContext(
|
|
1249
|
+
const actorContext = resolveTurnInboundActorContext(
|
|
1250
|
+
turnOrRestingTrust(ctx),
|
|
1251
|
+
);
|
|
1210
1252
|
ctx.currentTurnInboundActorContext = actorContext;
|
|
1211
1253
|
|
|
1212
1254
|
// Surface long gaps between user messages so the model can acknowledge
|
|
@@ -1925,6 +1967,7 @@ export async function runAgentLoopImpl(
|
|
|
1925
1967
|
state.toolContentBlockToolNames,
|
|
1926
1968
|
);
|
|
1927
1969
|
const { assistantAttachments, emittedAttachments } = attachmentResult;
|
|
1970
|
+
persistedAttachmentFiles = attachmentResult.persistedFiles;
|
|
1928
1971
|
|
|
1929
1972
|
ctx.lastAssistantAttachments = assistantAttachments;
|
|
1930
1973
|
ctx.lastAttachmentWarnings = attachmentResult.directiveWarnings;
|
|
@@ -2104,6 +2147,31 @@ export async function runAgentLoopImpl(
|
|
|
2104
2147
|
try {
|
|
2105
2148
|
if (turnStarted) {
|
|
2106
2149
|
ctx.turnCount++;
|
|
2150
|
+
|
|
2151
|
+
// Activation checklist: a completed turn in a conversation an
|
|
2152
|
+
// activation task was launched into finishes that task, unless the
|
|
2153
|
+
// turn ended waiting on the user, in which case the answer's turn
|
|
2154
|
+
// finishes it (see `markActivationTurnComplete`). Cancelled turns
|
|
2155
|
+
// and handoffs deliberately fall through: the task is still
|
|
2156
|
+
// running. No-op for every conversation no task points at.
|
|
2157
|
+
//
|
|
2158
|
+
// Ahead of the turn-boundary commit, and fire-and-forget: a commit
|
|
2159
|
+
// that fails, times out, or is deferred to the next turn must not
|
|
2160
|
+
// leave the task showing as still running while the client is
|
|
2161
|
+
// already free to restart it.
|
|
2162
|
+
if (turnCompleted) {
|
|
2163
|
+
onActivationTurnComplete({
|
|
2164
|
+
conversationId: ctx.conversationId,
|
|
2165
|
+
toolCallCount: state.toolUseIdToName.size,
|
|
2166
|
+
attachedFiles: persistedAttachmentFiles.map((file) => ({
|
|
2167
|
+
path: file.sourcePath,
|
|
2168
|
+
filename: file.displayName,
|
|
2169
|
+
sourceType: file.sourceType,
|
|
2170
|
+
})),
|
|
2171
|
+
endedAwaitingUser: turnEndedAwaitingUser(ctx),
|
|
2172
|
+
});
|
|
2173
|
+
}
|
|
2174
|
+
|
|
2107
2175
|
const runTurnCommit = async (): Promise<void> => {
|
|
2108
2176
|
const config = getConfig();
|
|
2109
2177
|
const maxWait =
|
|
@@ -10,6 +10,7 @@ import { getLogger } from "../util/logger.js";
|
|
|
10
10
|
import {
|
|
11
11
|
type ApproveHostRead,
|
|
12
12
|
type AssistantAttachmentDraft,
|
|
13
|
+
type AttachmentSourceType,
|
|
13
14
|
contentBlocksToDrafts,
|
|
14
15
|
deduplicateDrafts,
|
|
15
16
|
type DirectiveRequest,
|
|
@@ -63,10 +64,30 @@ export async function approveHostAttachmentRead(
|
|
|
63
64
|
return response.decision === "allow";
|
|
64
65
|
}
|
|
65
66
|
|
|
67
|
+
/**
|
|
68
|
+
* A file the assistant named that survived resolution, validation, and
|
|
69
|
+
* persistence. Rejected directives (missing, oversized, denied) and drafts
|
|
70
|
+
* whose upload was skipped never appear here, so a caller pointing a user
|
|
71
|
+
* at "the files this turn produced" cannot offer a broken one.
|
|
72
|
+
*/
|
|
73
|
+
export interface PersistedAttachmentFile {
|
|
74
|
+
/** Absolute path the attachment was read from. */
|
|
75
|
+
sourcePath: string;
|
|
76
|
+
/** Name the attachment was persisted under. */
|
|
77
|
+
displayName: string;
|
|
78
|
+
/**
|
|
79
|
+
* Boundary the path was resolved against. A caller that surfaces the path
|
|
80
|
+
* needs it: only `sandbox_file` paths are the assistant's own workspace,
|
|
81
|
+
* and a `host_file` path is the user's machine.
|
|
82
|
+
*/
|
|
83
|
+
sourceType: AttachmentSourceType;
|
|
84
|
+
}
|
|
85
|
+
|
|
66
86
|
export interface AttachmentResolutionResult {
|
|
67
87
|
assistantAttachments: AssistantAttachmentDraft[];
|
|
68
88
|
emittedAttachments: UserMessageAttachment[];
|
|
69
89
|
directiveWarnings: string[];
|
|
90
|
+
persistedFiles: PersistedAttachmentFile[];
|
|
70
91
|
}
|
|
71
92
|
|
|
72
93
|
/**
|
|
@@ -84,6 +105,17 @@ export async function resolveAssistantAttachments(
|
|
|
84
105
|
): Promise<AttachmentResolutionResult> {
|
|
85
106
|
let assistantAttachments: AssistantAttachmentDraft[] = [];
|
|
86
107
|
const emittedAttachments: UserMessageAttachment[] = [];
|
|
108
|
+
const persistedFiles: PersistedAttachmentFile[] = [];
|
|
109
|
+
|
|
110
|
+
const recordPersistedFile = (draft: AssistantAttachmentDraft): void => {
|
|
111
|
+
if (draft.sourcePath) {
|
|
112
|
+
persistedFiles.push({
|
|
113
|
+
sourcePath: draft.sourcePath,
|
|
114
|
+
displayName: draft.filename,
|
|
115
|
+
sourceType: draft.sourceType,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
};
|
|
87
119
|
|
|
88
120
|
log.info(
|
|
89
121
|
{
|
|
@@ -208,6 +240,7 @@ export async function resolveAssistantAttachments(
|
|
|
208
240
|
}
|
|
209
241
|
}
|
|
210
242
|
|
|
243
|
+
recordPersistedFile(draft);
|
|
211
244
|
emittedAttachments.push({
|
|
212
245
|
id: stored.id,
|
|
213
246
|
filename: draft.filename,
|
|
@@ -220,6 +253,9 @@ export async function resolveAssistantAttachments(
|
|
|
220
253
|
});
|
|
221
254
|
}
|
|
222
255
|
} else if (assistantAttachments.length > 0) {
|
|
256
|
+
// No assistant message to attach to: the drafts are emitted to the client
|
|
257
|
+
// for this turn only and nothing is stored, so none of them is a
|
|
258
|
+
// persisted file.
|
|
223
259
|
for (const draft of assistantAttachments) {
|
|
224
260
|
emittedAttachments.push({
|
|
225
261
|
filename: draft.filename,
|
|
@@ -230,5 +266,10 @@ export async function resolveAssistantAttachments(
|
|
|
230
266
|
}
|
|
231
267
|
}
|
|
232
268
|
|
|
233
|
-
return {
|
|
269
|
+
return {
|
|
270
|
+
assistantAttachments,
|
|
271
|
+
emittedAttachments,
|
|
272
|
+
directiveWarnings,
|
|
273
|
+
persistedFiles,
|
|
274
|
+
};
|
|
234
275
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { AssistantEvent } from "../api/index.js";
|
|
2
|
+
import { broadcastMessage } from "../runtime/assistant-event-hub.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Delivery sink for a top-level conversation: the assistant event hub,
|
|
6
|
+
* scoped to that conversation.
|
|
7
|
+
*
|
|
8
|
+
* The hub attributes an event to a conversation from the id it is handed,
|
|
9
|
+
* falling back to a `conversationId` on the payload, and that attribution
|
|
10
|
+
* decides three things: which conversation-filtered subscribers receive the
|
|
11
|
+
* event, whether it is seq-stamped, and whether it is replayed after a
|
|
12
|
+
* reconnect. Binding the id here makes all three a property of the emitting
|
|
13
|
+
* conversation rather than of each event's schema, so an event whose payload
|
|
14
|
+
* names no conversation (`subagent_spawned`, `subagent_status_changed`) is
|
|
15
|
+
* scoped exactly like one that does.
|
|
16
|
+
*
|
|
17
|
+
* Payload attribution stays the fallback for events published straight to the
|
|
18
|
+
* hub, including `open_conversation`, whose id names the conversation to open
|
|
19
|
+
* rather than the one it was emitted from.
|
|
20
|
+
*/
|
|
21
|
+
export function conversationEventSink(
|
|
22
|
+
conversationId: string,
|
|
23
|
+
): (msg: AssistantEvent) => void {
|
|
24
|
+
return (msg) => broadcastMessage(msg, conversationId);
|
|
25
|
+
}
|
|
@@ -64,7 +64,7 @@ import { preactivateHostProxySkills } from "./host-proxy-preactivation.js";
|
|
|
64
64
|
import type { UserMessageAttachment } from "./message-protocol.js";
|
|
65
65
|
import { buildTransportHints } from "./transport-hints.js";
|
|
66
66
|
import { sameTrustIdentity, type TrustContext } from "./trust-context-types.js";
|
|
67
|
-
import { turnOrRestingTrust } from "./trust-context-types.js";
|
|
67
|
+
import { restingTrust, turnOrRestingTrust } from "./trust-context-types.js";
|
|
68
68
|
import { resolveVerificationSessionIntent } from "./verification-session-intent.js";
|
|
69
69
|
|
|
70
70
|
const log = getLogger("conversation-process");
|
|
@@ -646,6 +646,52 @@ export async function kickQueueDrain(
|
|
|
646
646
|
}
|
|
647
647
|
}
|
|
648
648
|
|
|
649
|
+
/**
|
|
650
|
+
* Commit `actor` as the actor the turn about to start runs for: the resting
|
|
651
|
+
* slot names them, so the history is scoped to them and every reader of the
|
|
652
|
+
* slot (the `<turn_context>` actor section, memory retrieval, the Slack
|
|
653
|
+
* transcript filters) describes them rather than whoever sent last, and the
|
|
654
|
+
* per-turn snapshot then reads back that same actor, which is also returned
|
|
655
|
+
* for callers that must hold it in a local. A caller with no actor of its own
|
|
656
|
+
* (internal dispatch, or a queued message whose enqueue found the slot empty)
|
|
657
|
+
* passes `undefined`, and the resting actor stands.
|
|
658
|
+
*
|
|
659
|
+
* `reloadHistory` is off only for a steered drain, which keeps its resident
|
|
660
|
+
* history: a steer comes from the actor whose turn it cut off, and that
|
|
661
|
+
* history may hold the in-memory repair of the abandoned `tool_use`, which a
|
|
662
|
+
* reload would discard.
|
|
663
|
+
*
|
|
664
|
+
* The committed actor is captured and snapshotted before the reload awaits:
|
|
665
|
+
* the slot is writable out-of-band across that await (a wake's stamp, a
|
|
666
|
+
* pointer elevation), and a read after it would hand the turn that writer's
|
|
667
|
+
* actor. A reload that fails starts no turn, so the slot is put back to what
|
|
668
|
+
* it held before, guarded so a writer that legitimately moved it in between
|
|
669
|
+
* is left alone.
|
|
670
|
+
*/
|
|
671
|
+
async function commitTurnActor(
|
|
672
|
+
conversation: Conversation,
|
|
673
|
+
actor: TrustContext | undefined,
|
|
674
|
+
options: { reloadHistory: boolean },
|
|
675
|
+
): Promise<TrustContext | undefined> {
|
|
676
|
+
const prior = restingTrust(conversation);
|
|
677
|
+
if (actor) {
|
|
678
|
+
conversation.setTrustContext(actor);
|
|
679
|
+
}
|
|
680
|
+
const turnTrustContext = restingTrust(conversation);
|
|
681
|
+
conversation.currentTurnTrustContext = turnTrustContext;
|
|
682
|
+
if (options.reloadHistory) {
|
|
683
|
+
try {
|
|
684
|
+
await conversation.ensureActorScopedHistory();
|
|
685
|
+
} catch (err) {
|
|
686
|
+
if (actor && restingTrust(conversation) === actor) {
|
|
687
|
+
conversation.setTrustContext(prior ?? null);
|
|
688
|
+
}
|
|
689
|
+
throw err;
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
return turnTrustContext;
|
|
693
|
+
}
|
|
694
|
+
|
|
649
695
|
async function drainSingleMessage(
|
|
650
696
|
conversation: Conversation,
|
|
651
697
|
next: QueuedMessage,
|
|
@@ -749,8 +795,11 @@ async function drainSingleMessage(
|
|
|
749
795
|
// Trust comes from the queued message, not the live slot: the slot holds
|
|
750
796
|
// whichever actor sent most recently, which is this sender only when nobody
|
|
751
797
|
// else sent while this message waited.
|
|
752
|
-
|
|
753
|
-
|
|
798
|
+
const turnTrustContext = await commitTurnActor(
|
|
799
|
+
conversation,
|
|
800
|
+
next.trustContext,
|
|
801
|
+
{ reloadHistory: !steered },
|
|
802
|
+
);
|
|
754
803
|
conversation.currentTurnChannelCapabilities =
|
|
755
804
|
conversation.channelCapabilities;
|
|
756
805
|
|
|
@@ -1220,10 +1269,10 @@ async function drainSingleMessage(
|
|
|
1220
1269
|
cronRunId?: string | null;
|
|
1221
1270
|
} = {
|
|
1222
1271
|
isUserMessage: true,
|
|
1223
|
-
// Carry the sender's trust into the run
|
|
1224
|
-
// per-turn snapshot on entry,
|
|
1225
|
-
//
|
|
1226
|
-
turnTrustContext
|
|
1272
|
+
// Carry the sender's trust into the run from the local captured at the
|
|
1273
|
+
// commit: the loop re-initializes the per-turn snapshot on entry, and the
|
|
1274
|
+
// field is writable out-of-band across the awaits above.
|
|
1275
|
+
turnTrustContext,
|
|
1227
1276
|
};
|
|
1228
1277
|
if (next.isInteractive !== undefined) {
|
|
1229
1278
|
drainLoopOptions.isInteractive = next.isInteractive;
|
|
@@ -1359,8 +1408,11 @@ async function drainBatch(
|
|
|
1359
1408
|
// `buildPassthroughBatch` refuses to coalesce messages from different
|
|
1360
1409
|
// actors; without that boundary this would run a tail under the head's
|
|
1361
1410
|
// trust.
|
|
1362
|
-
|
|
1363
|
-
|
|
1411
|
+
const turnTrustContext = await commitTurnActor(
|
|
1412
|
+
conversation,
|
|
1413
|
+
head.trustContext,
|
|
1414
|
+
{ reloadHistory: true },
|
|
1415
|
+
);
|
|
1364
1416
|
conversation.currentTurnChannelCapabilities =
|
|
1365
1417
|
conversation.channelCapabilities;
|
|
1366
1418
|
|
|
@@ -1703,9 +1755,9 @@ async function drainBatch(
|
|
|
1703
1755
|
cronRunId?: string | null;
|
|
1704
1756
|
} = {
|
|
1705
1757
|
isUserMessage: true,
|
|
1706
|
-
// Same reason as the single-message drain: the
|
|
1707
|
-
//
|
|
1708
|
-
turnTrustContext
|
|
1758
|
+
// Same reason as the single-message drain: the head's trust travels from
|
|
1759
|
+
// the local captured at the commit, not a late read of the field.
|
|
1760
|
+
turnTrustContext,
|
|
1709
1761
|
};
|
|
1710
1762
|
if (lastPushEligibleUserMessageId !== undefined) {
|
|
1711
1763
|
drainLoopOptions.notifyUserMessageId = lastPushEligibleUserMessageId;
|
|
@@ -1838,20 +1890,16 @@ export async function processMessage(
|
|
|
1838
1890
|
metadata: callerMetadata,
|
|
1839
1891
|
trustContext: committingTrustContext,
|
|
1840
1892
|
} = options;
|
|
1841
|
-
if (committingTrustContext) {
|
|
1842
|
-
conversation.setTrustContext(committingTrustContext);
|
|
1843
|
-
}
|
|
1844
|
-
await conversation.ensureActorScopedHistory();
|
|
1845
|
-
// Snapshot persona context at turn start so later tool turns can't pick up
|
|
1846
|
-
// a different actor's context if a concurrent request mutates the live fields.
|
|
1847
|
-
//
|
|
1848
1893
|
// Held in a local as well as on the conversation: the field is writable
|
|
1849
1894
|
// out-of-band while this turn is in flight (`agent-wake` stamps it and
|
|
1850
1895
|
// restores the prior value in a `finally`), so reading it back at the agent
|
|
1851
1896
|
// loop call below would reintroduce the late read this capture exists to
|
|
1852
1897
|
// avoid. The local is what the loop runs under.
|
|
1853
|
-
const turnTrustContext =
|
|
1854
|
-
|
|
1898
|
+
const turnTrustContext = await commitTurnActor(
|
|
1899
|
+
conversation,
|
|
1900
|
+
committingTrustContext,
|
|
1901
|
+
{ reloadHistory: true },
|
|
1902
|
+
);
|
|
1855
1903
|
conversation.currentTurnAuthContext = conversation.authContext;
|
|
1856
1904
|
conversation.currentTurnSourceActorPrincipalId =
|
|
1857
1905
|
sourceActorPrincipalId ?? conversation.authContext?.actorPrincipalId;
|
|
@@ -27,10 +27,10 @@ import {
|
|
|
27
27
|
} from "../providers/connection-resolution.js";
|
|
28
28
|
import { RateLimitProvider } from "../providers/ratelimit.js";
|
|
29
29
|
import { listProviders } from "../providers/registry.js";
|
|
30
|
-
import { broadcastMessage } from "../runtime/assistant-event-hub.js";
|
|
31
30
|
import { getSubagentManager } from "../subagent/index.js";
|
|
32
31
|
import { getSandboxWorkingDir } from "../util/platform.js";
|
|
33
32
|
import { Conversation } from "./conversation.js";
|
|
33
|
+
import { conversationEventSink } from "./conversation-event-sink.js";
|
|
34
34
|
import {
|
|
35
35
|
removeFromEvictor,
|
|
36
36
|
touchConversation,
|
|
@@ -377,8 +377,9 @@ async function acquireConversation(
|
|
|
377
377
|
provider,
|
|
378
378
|
systemPrompt,
|
|
379
379
|
// Top-level conversations deliver to the SSE hub for their whole life,
|
|
380
|
-
// so every subscribed client sees every event with no per-turn wiring
|
|
381
|
-
|
|
380
|
+
// so every subscribed client sees every event with no per-turn wiring,
|
|
381
|
+
// scoped to this conversation whatever the event's payload carries.
|
|
382
|
+
conversationEventSink(conversationId),
|
|
382
383
|
workingDir,
|
|
383
384
|
{
|
|
384
385
|
maxTokens,
|
|
@@ -166,6 +166,24 @@ const NON_BLOCKING_PENDING_SURFACE_TYPES = new Set<SurfaceType>([
|
|
|
166
166
|
"voice_picker",
|
|
167
167
|
]);
|
|
168
168
|
|
|
169
|
+
/**
|
|
170
|
+
* Whether a surface this conversation showed is still waiting on the user.
|
|
171
|
+
*
|
|
172
|
+
* The one-interactive-surface-at-a-time gate reads it to reject a second
|
|
173
|
+
* card, and the turn boundary reads it to tell a turn that delivered from a
|
|
174
|
+
* turn that posed a question and handed control back.
|
|
175
|
+
*/
|
|
176
|
+
export function hasBlockingPendingSurface(ctx: {
|
|
177
|
+
pendingSurfaceActions: Map<string, { surfaceType: SurfaceType }>;
|
|
178
|
+
}): boolean {
|
|
179
|
+
for (const entry of ctx.pendingSurfaceActions.values()) {
|
|
180
|
+
if (!NON_BLOCKING_PENDING_SURFACE_TYPES.has(entry.surfaceType)) {
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
|
|
169
187
|
/**
|
|
170
188
|
* Surface types that carry no terminal action: the card settles when the user
|
|
171
189
|
* interacts with it, so no click could ever satisfy an attached `actions`
|
|
@@ -3546,10 +3564,7 @@ export async function surfaceProxyResolver(
|
|
|
3546
3564
|
// content rather than a question posed to the user, so a pending one
|
|
3547
3565
|
// never blocks the next surface.
|
|
3548
3566
|
if (awaitAction) {
|
|
3549
|
-
|
|
3550
|
-
(entry) => !NON_BLOCKING_PENDING_SURFACE_TYPES.has(entry.surfaceType),
|
|
3551
|
-
);
|
|
3552
|
-
if (hasExistingPending) {
|
|
3567
|
+
if (hasBlockingPendingSurface(ctx)) {
|
|
3553
3568
|
return {
|
|
3554
3569
|
content:
|
|
3555
3570
|
"Another interactive surface is already awaiting user input. Present one at a time — wait for the user to respond to the current surface before showing the next.",
|
|
@@ -18,6 +18,8 @@ export const SYNC_TAGS = {
|
|
|
18
18
|
documentsList: "documents:list",
|
|
19
19
|
pluginsList: "plugins:list",
|
|
20
20
|
conversationsList: "conversations:list",
|
|
21
|
+
/** Activation-checklist progress: task launches, step counts, completions. */
|
|
22
|
+
activationProgress: "activation:progress",
|
|
21
23
|
featureFlagsClient: "feature-flags:client",
|
|
22
24
|
featureFlagsAssistant: "feature-flags:assistant",
|
|
23
25
|
/** ACP credential-failure markers, which drive the inline Connect card.
|
|
@@ -58,6 +58,7 @@ import type {
|
|
|
58
58
|
import { RouteResponse } from "../runtime/routes/types.js";
|
|
59
59
|
import { getLogger } from "../util/logger.js";
|
|
60
60
|
import { mapGatewayIpcConnectError } from "./gateway-ipc-errors.js";
|
|
61
|
+
import { ACTIVATION_SYNC_IPC_METHODS } from "./routes/activation-sync-ipc-routes.js";
|
|
61
62
|
import { CONTACTS_INFO_IPC_METHODS } from "./routes/contacts-info-ipc-routes.js";
|
|
62
63
|
import { CONTACTS_MIRROR_IPC_METHODS } from "./routes/contacts-mirror-ipc-routes.js";
|
|
63
64
|
import { CONVERSATION_SYNC_IPC_METHODS } from "./routes/conversation-sync-ipc-routes.js";
|
|
@@ -219,6 +220,7 @@ export class AssistantIpcServer {
|
|
|
219
220
|
GUARDIAN_LABEL_IPC_METHODS,
|
|
220
221
|
CONVERSATION_SYNC_IPC_METHODS,
|
|
221
222
|
DOCUMENTS_SYNC_IPC_METHODS,
|
|
223
|
+
ACTIVATION_SYNC_IPC_METHODS,
|
|
222
224
|
EVENTS_IPC_METHODS,
|
|
223
225
|
]) {
|
|
224
226
|
for (const [operationId, handler] of Object.entries(methodMap)) {
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The daemon-side handler for the worker → daemon activation-progress
|
|
3
|
+
* hand-off. A sidecar worker's own hub has no SSE subscriber, so it asks the
|
|
4
|
+
* daemon to republish the invalidation where clients actually observe it.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
8
|
+
|
|
9
|
+
const publishCalls: Array<string | undefined> = [];
|
|
10
|
+
|
|
11
|
+
mock.module("../../../runtime/sync/resource-sync-events.js", () => ({
|
|
12
|
+
publishActivationProgressChanged: (originClientId?: string) => {
|
|
13
|
+
publishCalls.push(originClientId);
|
|
14
|
+
},
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
import { DB_MIGRATION_READINESS_EXEMPT_OPERATIONS } from "../../../daemon/daemon-readiness.js";
|
|
18
|
+
import { NOTIFY_ACTIVATION_PROGRESS_CHANGED_IPC_METHOD } from "../../../runtime/sync/worker-daemon-notify.js";
|
|
19
|
+
import {
|
|
20
|
+
ACTIVATION_SYNC_IPC_METHODS,
|
|
21
|
+
handleNotifyActivationProgressChanged,
|
|
22
|
+
} from "../activation-sync-ipc-routes.js";
|
|
23
|
+
|
|
24
|
+
describe("activation-sync IPC route", () => {
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
publishCalls.length = 0;
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("republishes the activation-progress invalidation on the daemon", () => {
|
|
30
|
+
const result = handleNotifyActivationProgressChanged({ body: {} });
|
|
31
|
+
|
|
32
|
+
expect(result).toEqual({ ok: true });
|
|
33
|
+
expect(publishCalls).toEqual([undefined]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("is reachable on the IPC surface under the shared method name", () => {
|
|
37
|
+
expect(
|
|
38
|
+
typeof ACTIVATION_SYNC_IPC_METHODS[
|
|
39
|
+
NOTIFY_ACTIVATION_PROGRESS_CHANGED_IPC_METHOD
|
|
40
|
+
],
|
|
41
|
+
).toBe("function");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("is DB-migration readiness gated (absent from the exempt set)", () => {
|
|
45
|
+
expect(
|
|
46
|
+
DB_MIGRATION_READINESS_EXEMPT_OPERATIONS.has(
|
|
47
|
+
NOTIFY_ACTIVATION_PROGRESS_CHANGED_IPC_METHOD,
|
|
48
|
+
),
|
|
49
|
+
).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IPC-only route the sidecar workers (schedule, memory) call after a turn
|
|
3
|
+
* moved a launched checklist task.
|
|
4
|
+
*
|
|
5
|
+
* Workers disable SSE seq stamping (`disableStreamSeqStamping`) so the daemon
|
|
6
|
+
* is the sole seq authority, and the SSE subscribers live in the daemon too. A
|
|
7
|
+
* worker that published `activation:progress` on its own hub would reach
|
|
8
|
+
* nobody, so a task worked by a scheduled or background turn would keep
|
|
9
|
+
* showing as running until the client refetched for some other reason.
|
|
10
|
+
*
|
|
11
|
+
* This route runs the publish on the daemon instead, where real subscribers
|
|
12
|
+
* observe it. The hand-off carries no originating client, so nothing suppresses
|
|
13
|
+
* the broadcast as its own echo.
|
|
14
|
+
*
|
|
15
|
+
* IPC-only: registered directly on the assistant IPC server (see
|
|
16
|
+
* `assistant-server.ts`), never in the shared `ROUTES` array. The handler
|
|
17
|
+
* touches no database, but the IPC server's uniform DB-migration gate still
|
|
18
|
+
* applies (the method is not exempt); the worker's call is best-effort and
|
|
19
|
+
* tolerates that.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { RouteHandlerArgs } from "../../runtime/routes/types.js";
|
|
23
|
+
import { publishActivationProgressChanged } from "../../runtime/sync/resource-sync-events.js";
|
|
24
|
+
import { NOTIFY_ACTIVATION_PROGRESS_CHANGED_IPC_METHOD } from "../../runtime/sync/worker-daemon-notify.js";
|
|
25
|
+
|
|
26
|
+
/** Republish a worker's activation-progress invalidation to daemon subscribers. */
|
|
27
|
+
export function handleNotifyActivationProgressChanged(_args: RouteHandlerArgs) {
|
|
28
|
+
publishActivationProgressChanged();
|
|
29
|
+
return { ok: true };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* IPC-only activation-sync methods, keyed by operationId. Registered directly
|
|
34
|
+
* on the assistant IPC server (see `assistant-server.ts`).
|
|
35
|
+
*/
|
|
36
|
+
export const ACTIVATION_SYNC_IPC_METHODS: Record<
|
|
37
|
+
string,
|
|
38
|
+
(args: RouteHandlerArgs) => unknown
|
|
39
|
+
> = {
|
|
40
|
+
[NOTIFY_ACTIVATION_PROGRESS_CHANGED_IPC_METHOD]:
|
|
41
|
+
handleNotifyActivationProgressChanged,
|
|
42
|
+
};
|
|
@@ -10,7 +10,7 @@ Guardian-request producers (access requests, tool approvals, tool-grant escalati
|
|
|
10
10
|
|
|
11
11
|
Approval-card **source references** (the link back to the channel message that triggered a request) resolve only through `resolveApprovalSourceReference()` in `runtime/approval-source-link.ts` -- producers spread the result into the `guardian.question` context payload and never hand-build links. Channel-format knowledge (id shapes, permalinks, mrkdwn) lives only in `messaging/providers/<channel>/` and `notifications/adapters/<channel>`; the four-layer ownership map is documented at the top of `approval-source-link.ts`. Exception: access-request cards predate the registry and still derive their Slack permalink from payload `messageTs` in `access-request-copy.ts` -- converge them onto the registry rather than adding a third resolution path.
|
|
12
12
|
|
|
13
|
-
A notification delivered to an external channel (Slack, Telegram, Discord) becomes a conversation row **only after the adapter acknowledges the send**. `pairDeliveryWithConversation` resolves the chat's home conversation before the send for `continue_existing_conversation` channels (`resolveProactiveHomeConversation`: the chat's thread-less inbound conversation, else its `notification:`-namespace conversation) and writes nothing there; on adapter success the broadcaster calls `recordDeliveredChannelPost`, which writes the sent text with the neutral `providerMeta` envelope and `automated: true`, runs the shared post-send reconciliation (`runtime/outbound-post-reconciliation.ts`) so the acknowledged id lands on the envelope and in `channel_outbound_posts`, and marks a resident conversation stale. The delivery audit names that row in `canonical_message_id`; `message_id` keeps its meaning (provider id for a channel delivery, row id for a vellum delivery). A failed or pending delivery therefore has no conversation row, so nothing the channel never accepted can read as the assistant's words. Vellum and passive deliveries keep their pre-send pairing row, which the feed card's deep link and rewritable row depend on. Editing a notification rewrites the canonical row after the channel update succeeds and re-indexes it; deleting the channel post resolves to the row through the index.
|
|
13
|
+
A notification delivered to an external channel (Slack, Telegram, Discord) becomes a conversation row **only after the adapter acknowledges the send**. `pairDeliveryWithConversation` resolves the chat's home conversation before the send for `continue_existing_conversation` channels (`resolveProactiveHomeConversation`: the chat's thread-less inbound conversation, else its `notification:`-namespace conversation; a post into a thread goes to the thread's own conversation as ingress resolves it, since Slack and Telegram key inbound conversations per thread and the replies arrive there) and writes nothing there; on adapter success the broadcaster calls `recordDeliveredChannelPost`, which writes the sent text with the neutral `providerMeta` envelope and `automated: true`, runs the shared post-send reconciliation (`runtime/outbound-post-reconciliation.ts`) so the acknowledged id lands on the envelope and in `channel_outbound_posts`, and marks a resident conversation stale. The delivery audit names that row in `canonical_message_id`; `message_id` keeps its meaning (provider id for a channel delivery, row id for a vellum delivery). A failed or pending delivery therefore has no conversation row, so nothing the channel never accepted can read as the assistant's words. Vellum and passive deliveries keep their pre-send pairing row, which the feed card's deep link and rewritable row depend on. Editing a notification rewrites the canonical row after the channel update succeeds and re-indexes it; deleting the channel post resolves to the row through the index.
|
|
14
14
|
|
|
15
15
|
Guardian-request card rows are **not conversation history**. Only the vellum delivery persists a message row (`pairDeliveryWithConversation` pins it to the conversation the request is _about_, via `buildVellumCardAffinity`); channel guardian cards are delivery projections and pair no conversation at all, with the gateway delivery row (chat id + channel-native message id) as their only persisted envelope. `isGuardianCardRow` in `approval-card-data.ts` is the single definition of which rows are guardian cards (including rows channel deliveries paired before the projection-only policy), derived from the card's own `ui_surface` id rather than a stored marker so old rows need no backfill. **Both** history assemblers must consult it -- `Conversation.loadFromDb` and `loadSlackChronologicalContext`, which re-reads rows rather than using `this.messages` -- or the unfiltered one replays the card between a parked turn's `tool_use` and its `tool_result` and history repair destroys the real result. Surface state is exempt on purpose: the card's buttons must still route after a restart. Full rationale in [docs/guardian-request-flow.md](../../docs/guardian-request-flow.md).
|
|
16
16
|
|