@vellumai/assistant 0.12.6 → 0.12.7-staging.1

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.
Files changed (97) hide show
  1. package/.env.example +2 -0
  2. package/ARCHITECTURE.md +14 -14
  3. package/docs/architecture/integrations.md +5 -5
  4. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +2 -0
  5. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/channels.ts +8 -4
  6. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +2 -0
  7. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/channels.ts +8 -4
  8. package/node_modules/@vellumai/gateway-client/src/__tests__/route-trust-class.test.ts +111 -0
  9. package/node_modules/@vellumai/gateway-client/src/admission-policy-contract.ts +5 -2
  10. package/node_modules/@vellumai/gateway-client/src/index.ts +9 -0
  11. package/node_modules/@vellumai/gateway-client/src/route-trust-class.ts +94 -0
  12. package/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +2 -0
  13. package/node_modules/@vellumai/service-contracts/src/channels.ts +8 -4
  14. package/openapi.yaml +153 -5
  15. package/package.json +1 -1
  16. package/src/__tests__/channel-policy.test.ts +7 -0
  17. package/src/__tests__/conversation-routes-disk-view.test.ts +141 -2
  18. package/src/__tests__/identity-routes.test.ts +1 -0
  19. package/src/__tests__/list-messages-client-message-id.test.ts +43 -0
  20. package/src/__tests__/openai-provider.test.ts +27 -0
  21. package/src/__tests__/skill-execute-input.test.ts +11 -0
  22. package/src/__tests__/tool-approval-handler.test.ts +4 -0
  23. package/src/__tests__/tool-preview-lifecycle.test.ts +26 -0
  24. package/src/agent/loop-unparseable-tool-retry.test.ts +333 -0
  25. package/src/agent/loop.ts +133 -12
  26. package/src/calls/__tests__/call-controller.test.ts +273 -1
  27. package/src/calls/__tests__/media-stream-server-integration.test.ts +40 -0
  28. package/src/calls/__tests__/media-stream-stt-session.test.ts +33 -0
  29. package/src/calls/call-constants.ts +10 -0
  30. package/src/calls/call-controller.ts +359 -25
  31. package/src/calls/media-stream-server.ts +18 -0
  32. package/src/calls/media-stream-stt-session.ts +30 -9
  33. package/src/channels/config.ts +8 -0
  34. package/src/cli/lib/bundled-marketplace.json +39 -0
  35. package/src/config/composer-preferences.test.ts +97 -0
  36. package/src/config/composer-preferences.ts +95 -0
  37. package/src/config/feature-flag-registry.json +16 -8
  38. package/src/config/schemas/llm.ts +1 -0
  39. package/src/contacts/types.ts +1 -0
  40. package/src/daemon/conversation-agent-loop-handlers.ts +18 -2
  41. package/src/daemon/conversation-agent-loop.ts +3 -0
  42. package/src/daemon/conversation-messaging.ts +4 -0
  43. package/src/daemon/conversation-process.ts +15 -0
  44. package/src/daemon/conversation-queue-manager.ts +2 -0
  45. package/src/daemon/conversation.ts +2 -0
  46. package/src/daemon/handlers/shared.ts +13 -2
  47. package/src/daemon/message-types/sync.ts +1 -0
  48. package/src/daemon/process-message.ts +5 -0
  49. package/src/i18n/__tests__/i18n.test.ts +25 -0
  50. package/src/i18n/assistant-text.ts +18 -0
  51. package/src/i18n/index.ts +1 -0
  52. package/src/i18n/messages.ts +11 -0
  53. package/src/ipc/routes/__tests__/route-adapter.test.ts +40 -0
  54. package/src/ipc/routes/route-adapter.ts +9 -0
  55. package/src/notifications/README.md +1 -1
  56. package/src/notifications/signal.ts +4 -0
  57. package/src/persistence/conversation-key-store.ts +12 -11
  58. package/src/providers/__tests__/unparseable-tool-args.test.ts +25 -0
  59. package/src/providers/inference/adapter-factory.ts +6 -0
  60. package/src/providers/ionet/client.test.ts +143 -0
  61. package/src/providers/ionet/client.ts +37 -0
  62. package/src/providers/model-catalog.ts +40 -0
  63. package/src/providers/openai/chat-completions-provider.ts +33 -2
  64. package/src/providers/retry.ts +10 -4
  65. package/src/providers/unparseable-tool-args.ts +29 -5
  66. package/src/runtime/__tests__/http-router-trust.test.ts +360 -0
  67. package/src/runtime/__tests__/shared-principal-lookup.test.ts +346 -0
  68. package/src/runtime/auth/__tests__/guard-tests.test.ts +51 -1
  69. package/src/runtime/auth/__tests__/route-policy.test.ts +85 -1
  70. package/src/runtime/auth/__tests__/scopes.test.ts +36 -0
  71. package/src/runtime/auth/route-policy.ts +44 -0
  72. package/src/runtime/auth/scopes.ts +15 -0
  73. package/src/runtime/auth/types.ts +1 -0
  74. package/src/runtime/channel-reply-delivery.ts +2 -0
  75. package/src/runtime/http-router.ts +61 -1
  76. package/src/runtime/http-types.ts +3 -0
  77. package/src/runtime/migrations/__tests__/vbundle-streaming-validator.test.ts +35 -10
  78. package/src/runtime/migrations/__tests__/vbundle-validator-v1-schema.test.ts +48 -0
  79. package/src/runtime/migrations/vbundle-builder.ts +19 -3
  80. package/src/runtime/migrations/vbundle-streaming-importer.ts +14 -19
  81. package/src/runtime/migrations/vbundle-streaming-validator.ts +14 -7
  82. package/src/runtime/migrations/vbundle-validator.ts +54 -1
  83. package/src/runtime/routes/composer-settings-routes.test.ts +71 -0
  84. package/src/runtime/routes/composer-settings-routes.ts +76 -0
  85. package/src/runtime/routes/conversation-routes.ts +54 -14
  86. package/src/runtime/routes/identity-routes.ts +2 -0
  87. package/src/runtime/routes/inbound-stages/background-dispatch.ts +4 -0
  88. package/src/runtime/routes/index.ts +2 -0
  89. package/src/runtime/routes/migration-routes.ts +3 -1
  90. package/src/runtime/routes/types.ts +4 -1
  91. package/src/runtime/shared-principal-lookup.ts +235 -0
  92. package/src/tools/host-terminal/host-shell.ts +4 -2
  93. package/src/tools/shared/shell-output.test.ts +42 -0
  94. package/src/tools/shared/shell-output.ts +27 -4
  95. package/src/tools/skills/execute.ts +10 -7
  96. package/src/tools/terminal/shell.ts +2 -0
  97. package/src/tools/tool-approval-handler.ts +9 -1
@@ -101,6 +101,7 @@ import {
101
101
  writeOnboardingSidecar,
102
102
  writeRelationshipState,
103
103
  } from "../../home/relationship-state-writer.js";
104
+ import { localeFromAcceptLanguage } from "../../i18n/index.js";
104
105
  import { ipcCall } from "../../ipc/gateway-client.js";
105
106
  import { buildSlackMessageDeepLinks } from "../../messaging/providers/slack/deep-link.js";
106
107
  import {
@@ -118,6 +119,7 @@ import {
118
119
  } from "../../persistence/attachments-store.js";
119
120
  import {
120
121
  addMessage,
122
+ deleteConversation,
121
123
  findMessageIdByClientMessageId,
122
124
  getConversation,
123
125
  getConversationPersistedSeq,
@@ -138,6 +140,7 @@ import {
138
140
  import {
139
141
  getConversationByKey,
140
142
  getOrCreateConversation,
143
+ recordStandardConversationCreated,
141
144
  } from "../../persistence/conversation-key-store.js";
142
145
  import { listConversationModeSessionsByIds } from "../../persistence/conversation-mode-sessions.js";
143
146
  import { searchConversations } from "../../persistence/conversation-queries.js";
@@ -915,7 +918,9 @@ function parseRequestedModeSessionIds(raw: unknown): string[] {
915
918
 
916
919
  export async function handleListMessages({
917
920
  queryParams,
921
+ headers,
918
922
  }: RouteHandlerArgs): Promise<Record<string, unknown>> {
923
+ const locale = localeFromAcceptLanguage(headers?.["accept-language"]);
919
924
  const conversationId = queryParams?.conversationId;
920
925
  const conversationKey = queryParams?.conversationKey;
921
926
 
@@ -1338,6 +1343,7 @@ export async function handleListMessages({
1338
1343
  attachmentBlocks,
1339
1344
  m.id ?? undefined,
1340
1345
  m.rowMetadata,
1346
+ m.role === "assistant" ? locale : undefined,
1341
1347
  );
1342
1348
 
1343
1349
  const toolCalls = enrichToolCallsWithQuestion(
@@ -1735,6 +1741,7 @@ export async function handleSendMessage(
1735
1741
  const principalType = headers?.["x-vellum-principal-type"];
1736
1742
  const originClientId = headers?.["x-vellum-client-id"]?.trim() || undefined;
1737
1743
  const clientMetadata = readClientMetadataHeaders(headers);
1744
+ const locale = localeFromAcceptLanguage(headers?.["accept-language"]);
1738
1745
 
1739
1746
  const { conversationKey, content, attachmentIds } = body;
1740
1747
  const inboundConversationId =
@@ -1924,6 +1931,12 @@ export async function handleSendMessage(
1924
1931
  conversationType: string;
1925
1932
  created: boolean;
1926
1933
  };
1934
+ const mintConversation =
1935
+ inboundConversationId === undefined &&
1936
+ !conversationKey &&
1937
+ sourceChannel === "vellum";
1938
+ const deferBootstrapCleanup =
1939
+ mintConversation && requestedRiskThreshold !== undefined;
1927
1940
  if (inboundConversationId !== undefined) {
1928
1941
  const existing = getConversation(inboundConversationId);
1929
1942
  if (!existing) {
@@ -1940,7 +1953,7 @@ export async function handleSendMessage(
1940
1953
  const resolvedConversationKey =
1941
1954
  conversationKey && conversationKey.length > 0
1942
1955
  ? conversationKey
1943
- : sourceChannel === "vellum"
1956
+ : mintConversation
1944
1957
  ? crypto.randomUUID()
1945
1958
  : `default:${sourceChannel}:${sourceInterface}`;
1946
1959
  // An onboarding flow may supply an explicit title for the conversation it
@@ -1955,26 +1968,51 @@ export async function handleSendMessage(
1955
1968
  // attributed from the moment it exists rather than on its first
1956
1969
  // message.
1957
1970
  origin: sourceChannel,
1971
+ ...(deferBootstrapCleanup ? { deferBootstrapCleanup: true } : {}),
1958
1972
  });
1959
1973
  }
1960
1974
 
1961
1975
  if (requestedRiskThreshold !== undefined) {
1962
- const result = await ipcCall("set_conversation_threshold", {
1963
- conversationId: mapping.conversationId,
1964
- threshold: requestedRiskThreshold,
1965
- });
1966
- if (result === undefined) {
1967
- log.error(
1968
- {
1969
- conversationId: mapping.conversationId,
1970
- threshold: requestedRiskThreshold,
1971
- },
1972
- "Failed to set conversation risk threshold override via gateway IPC",
1973
- );
1974
- throw new InternalError("Failed to persist risk threshold override");
1976
+ try {
1977
+ const result = await ipcCall("set_conversation_threshold", {
1978
+ conversationId: mapping.conversationId,
1979
+ threshold: requestedRiskThreshold,
1980
+ });
1981
+ if (result === undefined) {
1982
+ log.error(
1983
+ {
1984
+ conversationId: mapping.conversationId,
1985
+ threshold: requestedRiskThreshold,
1986
+ },
1987
+ "Failed to set conversation risk threshold override via gateway IPC",
1988
+ );
1989
+ throw new InternalError("Failed to persist risk threshold override");
1990
+ }
1991
+ } catch (error) {
1992
+ // Keyed chats can have concurrent senders. Only this request owns an
1993
+ // unannounced, server-minted chat with no messages.
1994
+ if (mintConversation && mapping.created) {
1995
+ try {
1996
+ if (hasMessages(mapping.conversationId)) {
1997
+ recordStandardConversationCreated();
1998
+ } else {
1999
+ deleteConversation(mapping.conversationId);
2000
+ }
2001
+ } catch (cleanupError) {
2002
+ log.error(
2003
+ { err: cleanupError, conversationId: mapping.conversationId },
2004
+ "Failed to roll back conversation after risk threshold failure",
2005
+ );
2006
+ }
2007
+ }
2008
+ throw error;
1975
2009
  }
1976
2010
  }
1977
2011
 
2012
+ if (deferBootstrapCleanup && mapping.created) {
2013
+ recordStandardConversationCreated();
2014
+ }
2015
+
1978
2016
  const smDeps = deps.sendMessageDeps;
1979
2017
 
1980
2018
  // Notify all connected clients that the conversation list changed when
@@ -2475,6 +2513,7 @@ export async function handleSendMessage(
2475
2513
  clientMetadata,
2476
2514
  ),
2477
2515
  isInteractive,
2516
+ locale,
2478
2517
  sourceActorPrincipalId,
2479
2518
  transport,
2480
2519
  clientMessageId,
@@ -3159,6 +3198,7 @@ export async function handleSendMessage(
3159
3198
  onEvent: broadcastMessage,
3160
3199
  isInteractive,
3161
3200
  isUserMessage: true,
3201
+ locale,
3162
3202
  turnTrustContext,
3163
3203
  ...(body.hidden === true ? { isHiddenPrompt: true } : {}),
3164
3204
  })
@@ -160,6 +160,7 @@ async function getDetailedHealth() {
160
160
  retryLastTurn: true,
161
161
  appPins: true,
162
162
  chatsSettings: true,
163
+ composerSettings: true,
163
164
  },
164
165
  ...(profiler ? { profiler } : {}),
165
166
  ...migrationHealthFields,
@@ -280,6 +281,7 @@ const healthCapabilitiesSchema = z.object({
280
281
  retryLastTurn: z.boolean(),
281
282
  appPins: z.boolean(),
282
283
  chatsSettings: z.boolean().optional(),
284
+ composerSettings: z.boolean().optional(),
283
285
  });
284
286
 
285
287
  const healthDiskSchema = z.object({
@@ -19,6 +19,7 @@ import {
19
19
  } from "../../../contacts/guardian-delivery-reader.js";
20
20
  import { isConversationBusyError } from "../../../daemon/conversation-messaging.js";
21
21
  import type { TrustContext } from "../../../daemon/trust-context-types.js";
22
+ import { negotiateLocale } from "../../../i18n/index.js";
22
23
  import type { ProviderMessageMetadata } from "../../../messaging/provider-message-metadata.js";
23
24
  import {
24
25
  channelActivityRefreshMs,
@@ -288,6 +289,9 @@ export function processChannelMessageInBackground(
288
289
  trustContext: trustCtx,
289
290
  author: trustCtx,
290
291
  isInteractive: resolveRoutingState(trustCtx).promptWaitingAllowed,
292
+ ...(sourceLanguageCode
293
+ ? { locale: negotiateLocale([sourceLanguageCode]) }
294
+ : {}),
291
295
  ...(displayContent !== undefined ? { displayContent } : {}),
292
296
  ...(cmdIntent ? { commandIntent: cmdIntent } : {}),
293
297
  ...(slackInbound ? { slackInbound } : {}),
@@ -44,6 +44,7 @@ import { ROUTES as CHANNEL_SEND_ROUTES } from "./channel-send-routes.js";
44
44
  import { ROUTES as CHANNEL_VERIFICATION_ROUTES } from "./channel-verification-routes.js";
45
45
  import { ROUTES as CHATGPT_SUBSCRIPTION_AUTH_ROUTES } from "./chatgpt-subscription-auth-routes.js";
46
46
  import { ROUTES as CLIENT_ROUTES } from "./client-routes.js";
47
+ import { ROUTES as COMPOSER_SETTINGS_ROUTES } from "./composer-settings-routes.js";
47
48
  import { ROUTES as CONSOLIDATION_ROUTES } from "./consolidation-routes.js";
48
49
  import { CONTACT_PROMPT_ROUTES } from "./contact-prompt-routes.js";
49
50
  import { ROUTES as CONTACT_ROUTES } from "./contact-routes.js";
@@ -199,6 +200,7 @@ export const ROUTES: RouteDefinition[] = [
199
200
  ...BTW_ROUTES,
200
201
  ...BRAIN_GRAPH_ROUTES,
201
202
  ...CLIENT_ROUTES,
203
+ ...COMPOSER_SETTINGS_ROUTES,
202
204
  ...CONTENT_SOURCE_ROUTES,
203
205
  ...CONTACT_PROMPT_ROUTES,
204
206
  ...GUARDIAN_FORM_ROUTES,
@@ -2056,7 +2056,9 @@ export async function handleMigrationPreflightFromGcs({
2056
2056
  }
2057
2057
 
2058
2058
  const bytes = new Uint8Array(await upstream.arrayBuffer());
2059
- const validationResult = validateVBundle(bytes);
2059
+ const validationResult = validateVBundle(bytes, {
2060
+ enforceStreamingLimits: true,
2061
+ });
2060
2062
 
2061
2063
  if (!validationResult.is_valid || !validationResult.manifest) {
2062
2064
  return {
@@ -186,7 +186,7 @@ export interface RouteDefinition {
186
186
  endpoint: string;
187
187
  method: string;
188
188
  /**
189
- * Scope + principal-type policy for this route.
189
+ * Scope, principal-type, and trust-class policy for this route.
190
190
  *
191
191
  * `null` means the route is intentionally unprotected — health
192
192
  * probes, public capability-token endpoints, and the like.
@@ -196,6 +196,9 @@ export interface RouteDefinition {
196
196
  * (`enforcePolicy()`) and the gateway IPC proxy (via the route
197
197
  * schema served from `get_route_schema`) read this field.
198
198
  *
199
+ * Its optional `allowedTrustClasses` names whose turn may call the
200
+ * route; absent means guardian only.
201
+ *
199
202
  * Required so the type system catches every new route — there is
200
203
  * no separate registry to forget to update.
201
204
  */
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Trust-class lookup for a shared-conversation principal.
3
+ *
4
+ * `local-principal-trust.ts` asks the gateway one question about a `vellum`
5
+ * principal (is it the guardian?), so it cannot classify a contact. Contacts
6
+ * in shared conversations live on the `vellum-shared` channel, and this module
7
+ * asks the question every other channel asks: the gateway's canonical
8
+ * per-actor classifier (`resolve_inbound_trust`, reached through
9
+ * {@link readInboundTrust}), with `vellum-shared` as the channel and the
10
+ * principal as the actor id. Classification stays gateway-owned; nothing
11
+ * here re-derives it from ACL rows.
12
+ *
13
+ * Results are cached per principal behind a short TTL and coalesced
14
+ * single-flight: the callers this exists for run per request, where
15
+ * {@link readInboundTrust} caches nothing because it serves call setup. A
16
+ * revoked contact keeps resolving from cache until the entry expires; callers
17
+ * that cannot tolerate that read {@link resolveSharedPrincipalFresh}, which
18
+ * also discards the entry for every later caller.
19
+ *
20
+ * Failures resolve `unknown` and are not cached: an unreachable gateway, a
21
+ * malformed response, and the resolver's own could-not-vouch sentinel all
22
+ * fail closed and are retried on the next call.
23
+ */
24
+
25
+ import type { TrustClass } from "@vellumai/gateway-client";
26
+
27
+ import { readInboundTrust } from "../calls/inbound-trust-reader.js";
28
+ import { getLogger } from "../util/logger.js";
29
+
30
+ const log = getLogger("shared-principal-lookup");
31
+
32
+ /** Bounds staleness after a revoke; callers needing none read fresh. */
33
+ const CACHE_TTL_MS = 30_000;
34
+
35
+ /** Keeps the map from growing without limit across principals. */
36
+ export const MAX_ENTRIES = 2000;
37
+
38
+ export interface SharedPrincipalTrust {
39
+ trustClass: TrustClass;
40
+ contactId?: string;
41
+ displayName?: string;
42
+ }
43
+
44
+ // Frozen: returned to every failing caller, so a mutation would travel.
45
+ const UNKNOWN: SharedPrincipalTrust = Object.freeze({ trustClass: "unknown" });
46
+
47
+ interface PrincipalState {
48
+ trust?: SharedPrincipalTrust;
49
+ expiresAt?: number;
50
+ inFlight?: Promise<SharedPrincipalTrust>;
51
+ /** Bumped when a read starts; only the newest read may write its result. */
52
+ generation: number;
53
+ /** Reads currently in flight for this principal. */
54
+ active: number;
55
+ }
56
+
57
+ const states = new Map<string, PrincipalState>();
58
+
59
+ function stateFor(principalId: string): PrincipalState {
60
+ const existing = states.get(principalId);
61
+ if (existing) {
62
+ return existing;
63
+ }
64
+ const created: PrincipalState = { generation: 0, active: 0 };
65
+ states.set(principalId, created);
66
+ return created;
67
+ }
68
+
69
+ /** Drops a principal holding neither a cached verdict nor a live read. */
70
+ function prune(principalId: string): void {
71
+ const state = states.get(principalId);
72
+ if (state && state.active === 0 && state.trust === undefined) {
73
+ states.delete(principalId);
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Evicts idle principals, oldest first, until the map holds at most `limit`.
79
+ *
80
+ * A burst of distinct principals can push the map past MAX_ENTRIES: every
81
+ * entry is mid-read, so nothing is evictable, and the insert happens anyway
82
+ * rather than refusing the caller. Nothing else would bring the map back
83
+ * down, so every settling read trims too, not just the next insert.
84
+ */
85
+ function trimTo(limit: number): void {
86
+ if (states.size <= limit) {
87
+ return;
88
+ }
89
+ for (const [key, state] of states) {
90
+ if (state.active > 0) {
91
+ continue;
92
+ }
93
+ states.delete(key);
94
+ if (states.size <= limit) {
95
+ return;
96
+ }
97
+ }
98
+ }
99
+
100
+ /** Makes room for a principal the map does not hold yet. */
101
+ function evictIfFull(principalId: string): void {
102
+ if (states.has(principalId)) {
103
+ return;
104
+ }
105
+ trimTo(MAX_ENTRIES - 1);
106
+ }
107
+
108
+ async function fetchTrust(
109
+ principalId: string,
110
+ ): Promise<{ trust: SharedPrincipalTrust; cacheable: boolean }> {
111
+ const result = await readInboundTrust({
112
+ channelType: "vellum-shared",
113
+ actorExternalId: principalId,
114
+ });
115
+
116
+ if (!result.ok) {
117
+ log.warn(
118
+ { principalId },
119
+ "shared principal trust unresolved: gateway read failed",
120
+ );
121
+ return { trust: UNKNOWN, cacheable: false };
122
+ }
123
+
124
+ const { verdict } = result;
125
+
126
+ if (verdict.resolutionFailed) {
127
+ log.warn(
128
+ { principalId },
129
+ "shared principal trust unresolved: gateway could not vouch",
130
+ );
131
+ return { trust: UNKNOWN, cacheable: false };
132
+ }
133
+
134
+ const trust: SharedPrincipalTrust = { trustClass: verdict.trustClass };
135
+ if (verdict.contactId !== undefined) {
136
+ trust.contactId = verdict.contactId;
137
+ }
138
+ const displayName = verdict.memberDisplayName ?? verdict.guardianDisplayName;
139
+ if (displayName !== undefined) {
140
+ trust.displayName = displayName;
141
+ }
142
+ return { trust, cacheable: true };
143
+ }
144
+
145
+ function read(
146
+ principalId: string,
147
+ forceRefresh: boolean,
148
+ ): Promise<SharedPrincipalTrust> {
149
+ const key = principalId.trim();
150
+ if (!key) {
151
+ return Promise.resolve(UNKNOWN);
152
+ }
153
+
154
+ if (!forceRefresh) {
155
+ const cached = states.get(key);
156
+ if (
157
+ cached?.trust &&
158
+ cached.expiresAt !== undefined &&
159
+ cached.expiresAt > Date.now()
160
+ ) {
161
+ return Promise.resolve(cached.trust);
162
+ }
163
+ if (cached?.inFlight) {
164
+ return cached.inFlight;
165
+ }
166
+ }
167
+
168
+ evictIfFull(key);
169
+ const state = stateFor(key);
170
+ if (forceRefresh) {
171
+ // The forced read supersedes the cached verdict, so a failed refresh
172
+ // cannot leave the pre-revoke answer serving until the TTL runs out.
173
+ state.trust = undefined;
174
+ state.expiresAt = undefined;
175
+ }
176
+ state.generation += 1;
177
+ state.active += 1;
178
+ const generation = state.generation;
179
+
180
+ const promise = fetchTrust(key)
181
+ .then(({ trust, cacheable }) => {
182
+ // A read that started earlier must not overwrite a newer verdict: a
183
+ // fresh read racing an in-flight cached read would otherwise restore
184
+ // the pre-revoke answer for the rest of the TTL.
185
+ if (cacheable && state.generation === generation) {
186
+ state.trust = trust;
187
+ state.expiresAt = Date.now() + CACHE_TTL_MS;
188
+ }
189
+ return trust;
190
+ })
191
+ .finally(() => {
192
+ state.active -= 1;
193
+ if (state.inFlight === promise) {
194
+ state.inFlight = undefined;
195
+ }
196
+ prune(key);
197
+ trimTo(MAX_ENTRIES);
198
+ });
199
+
200
+ state.inFlight = promise;
201
+ return promise;
202
+ }
203
+
204
+ /**
205
+ * Trust class for a shared-conversation principal, from cache when fresh.
206
+ * Resolves `unknown` for an unknown, revoked, or blocked principal, and for
207
+ * any read the gateway could not complete.
208
+ */
209
+ export function resolveSharedPrincipal(
210
+ principalId: string,
211
+ ): Promise<SharedPrincipalTrust> {
212
+ return read(principalId, false);
213
+ }
214
+
215
+ /**
216
+ * Uncached variant of {@link resolveSharedPrincipal}, for callers whose threat
217
+ * model is the stale entry itself. Discards the cached entry when it starts,
218
+ * so cached reads made while it is pending share its result, and repopulates
219
+ * the cache on success.
220
+ */
221
+ export function resolveSharedPrincipalFresh(
222
+ principalId: string,
223
+ ): Promise<SharedPrincipalTrust> {
224
+ return read(principalId, true);
225
+ }
226
+
227
+ /** Test-only: number of principals currently held. */
228
+ export function __sharedPrincipalCacheSizeForTest(): number {
229
+ return states.size;
230
+ }
231
+
232
+ /** Test-only: reset cache + in-flight state for deterministic test runs. */
233
+ export function __resetSharedPrincipalCacheForTest(): void {
234
+ states.clear();
235
+ }
@@ -486,7 +486,7 @@ export const hostShellTool = {
486
486
  // Only the first handler to fire should wake the agent.
487
487
  let completed = false;
488
488
 
489
- child.on("close", (code) => {
489
+ child.on("close", (code, signal) => {
490
490
  if (completed) {
491
491
  return;
492
492
  }
@@ -494,6 +494,7 @@ export const hostShellTool = {
494
494
  clearTimeout(timer);
495
495
  const result = collector.format(code, timedOut, timeoutSec, {
496
496
  started: launch.didStart(),
497
+ signal: aborted ? null : signal,
497
498
  });
498
499
  // Cancel takes precedence over the SIGKILL-induced error result.
499
500
  const status = aborted
@@ -667,12 +668,13 @@ export const hostShellTool = {
667
668
  }
668
669
  }
669
670
 
670
- child.on("close", (code) => {
671
+ child.on("close", (code, signal) => {
671
672
  clearTimeout(timer);
672
673
  context.signal?.removeEventListener("abort", onAbort);
673
674
 
674
675
  const result = collector.format(code, timedOut, timeoutSec, {
675
676
  started: launch.didStart(),
677
+ signal: context.signal?.aborted ? null : signal,
676
678
  });
677
679
 
678
680
  resolve({
@@ -112,3 +112,45 @@ describe("formatShellOutput truncation", () => {
112
112
  expect(result.content).toContain(OUTPUT_TRUNCATED_TAG);
113
113
  });
114
114
  });
115
+
116
+ describe("formatShellOutput kill reporting", () => {
117
+ test("names the signal and hints at memory for a SIGKILL", () => {
118
+ const result = formatShellOutput("", "", null, false, 120, {
119
+ signal: "SIGKILL",
120
+ });
121
+ expect(result.isError).toBe(true);
122
+ expect(result.content).toContain('<command_killed signal="SIGKILL" />');
123
+ expect(result.content).toContain("out of memory");
124
+ expect(result.content).not.toContain('code="null"');
125
+ expect(result.status).toBe('<command_killed signal="SIGKILL" />');
126
+ });
127
+
128
+ test("keeps the output produced before the kill", () => {
129
+ const result = formatShellOutput("partial", "", null, false, 120, {
130
+ signal: "SIGTERM",
131
+ });
132
+ expect(result.content.startsWith("partial\n")).toBe(true);
133
+ expect(result.content).toContain("killed by SIGTERM");
134
+ expect(result.content).not.toContain("out of memory");
135
+ });
136
+
137
+ test("does not infer a kill from a 128 + n exit code", () => {
138
+ const result = formatShellOutput("", "Killed", 137, false, 120);
139
+ expect(result.content).toBe("Killed");
140
+ expect(result.status).toBe('<command_exit code="137" />');
141
+ });
142
+
143
+ test("leaves a timeout framed as a timeout", () => {
144
+ const result = formatShellOutput("", "", null, true, 120, {
145
+ signal: "SIGKILL",
146
+ });
147
+ expect(result.content).toContain("<command_timeout");
148
+ expect(result.content).not.toContain("command_killed");
149
+ });
150
+
151
+ test("leaves an ordinary failure unchanged", () => {
152
+ const result = formatShellOutput("", "boom", 1, false, 120);
153
+ expect(result.content).toBe("boom");
154
+ expect(result.status).toBe('<command_exit code="1" />');
155
+ });
156
+ });
@@ -13,6 +13,15 @@ export interface ShellOutputResult {
13
13
  isError: boolean;
14
14
  }
15
15
 
16
+ function describeKill(signal: NodeJS.Signals): string {
17
+ const tag = `<command_killed signal="${signal}" />`;
18
+ const hint =
19
+ signal === "SIGKILL"
20
+ ? " A SIGKILL the command did not send itself usually means the system ran out of memory."
21
+ : "";
22
+ return `${tag}\nCommand was killed by ${signal} before it finished.${hint}`;
23
+ }
24
+
16
25
  type StdioStream = "stdout" | "stderr";
17
26
 
18
27
  type DataListener = (data: Buffer | string) => void;
@@ -34,7 +43,11 @@ export function formatShellOutput(
34
43
  code: number | null,
35
44
  timedOut: boolean,
36
45
  timeoutSec: number,
37
- options?: { truncated?: boolean; started?: boolean },
46
+ options?: {
47
+ truncated?: boolean;
48
+ started?: boolean;
49
+ signal?: NodeJS.Signals | null;
50
+ },
38
51
  ): ShellOutputResult {
39
52
  if (options?.started === false) {
40
53
  return {
@@ -66,6 +79,12 @@ export function formatShellOutput(
66
79
  statusParts.push(OUTPUT_TRUNCATED_TAG);
67
80
  }
68
81
 
82
+ const killSignal = timedOut ? null : (options?.signal ?? null);
83
+ if (killSignal) {
84
+ output += (output ? "\n" : "") + describeKill(killSignal);
85
+ statusParts.push(`<command_killed signal="${killSignal}" />`);
86
+ }
87
+
69
88
  if (!output.trim()) {
70
89
  if (code === 0) {
71
90
  output = "<command_completed />";
@@ -74,7 +93,7 @@ export function formatShellOutput(
74
93
  output = `${exitTag}\nCommand failed with exit code ${code}. No stdout or stderr output was produced.`;
75
94
  statusParts.push(exitTag);
76
95
  }
77
- } else if (code !== 0 && !timedOut) {
96
+ } else if (code !== 0 && !timedOut && !killSignal) {
78
97
  statusParts.push(`<command_exit code="${code}" />`);
79
98
  }
80
99
 
@@ -148,7 +167,7 @@ export class BoundedStdioCollector {
148
167
  code: number | null,
149
168
  timedOut: boolean,
150
169
  timeoutSec: number,
151
- options?: { started?: boolean },
170
+ options?: { started?: boolean; signal?: NodeJS.Signals | null },
152
171
  ): ShellOutputResult {
153
172
  return formatShellOutput(
154
173
  Buffer.concat(this.stdoutParts).toString(),
@@ -156,7 +175,11 @@ export class BoundedStdioCollector {
156
175
  code,
157
176
  timedOut,
158
177
  timeoutSec,
159
- { truncated: this.truncated, started: options?.started },
178
+ {
179
+ truncated: this.truncated,
180
+ started: options?.started,
181
+ signal: options?.signal,
182
+ },
160
183
  );
161
184
  }
162
185
  }
@@ -1,3 +1,5 @@
1
+ import { z } from "zod";
2
+
1
3
  import { RiskLevel } from "../../permissions/types.js";
2
4
  import { isUnparseableToolArgs } from "../../providers/unparseable-tool-args.js";
3
5
  import { declareDaemonActivityField } from "../schema-transforms.js";
@@ -9,6 +11,9 @@ import type {
9
11
 
10
12
  /** Envelope keys consumed by `skill_execute` itself, never inner-tool params. */
11
13
  const SKILL_EXECUTE_ENVELOPE_KEYS = new Set(["tool", "input", "activity"]);
14
+ const recoveredSkillExecuteEnvelopeSchema = z
15
+ .object({ tool: z.string() })
16
+ .passthrough();
12
17
 
13
18
  /**
14
19
  * Recover a `skill_execute` envelope that the provider layer wrapped under the
@@ -30,13 +35,11 @@ export function recoverSkillExecuteEnvelope(
30
35
  return envelope;
31
36
  }
32
37
  try {
33
- const parsed: unknown = JSON.parse(envelope._raw);
34
- if (
35
- parsed != null &&
36
- typeof parsed === "object" &&
37
- !Array.isArray(parsed)
38
- ) {
39
- return parsed as Record<string, unknown>;
38
+ const recovered = recoveredSkillExecuteEnvelopeSchema.safeParse(
39
+ JSON.parse(envelope._raw),
40
+ );
41
+ if (recovered.success) {
42
+ return recovered.data;
40
43
  }
41
44
  } catch {
42
45
  // Genuinely malformed/truncated — leave wrapped for the retryable error.
@@ -374,6 +374,7 @@ export const shellTool = {
374
374
 
375
375
  const fmtResult = collector.format(code, timedOut, timeoutSec, {
376
376
  started: launch.didStart(),
377
+ signal: aborted ? null : signal,
377
378
  });
378
379
 
379
380
  const status: BackgroundToolCompletedEvent["status"] = aborted
@@ -585,6 +586,7 @@ export const shellTool = {
585
586
 
586
587
  const fmtResult = collector.format(code, timedOut, timeoutSec, {
587
588
  started: launch.didStart(),
589
+ signal: context.signal?.aborted ? null : signal,
588
590
  });
589
591
 
590
592
  resolve({