@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.
- package/.env.example +2 -0
- package/ARCHITECTURE.md +14 -14
- package/docs/architecture/integrations.md +5 -5
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +2 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/channels.ts +8 -4
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +2 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/channels.ts +8 -4
- package/node_modules/@vellumai/gateway-client/src/__tests__/route-trust-class.test.ts +111 -0
- package/node_modules/@vellumai/gateway-client/src/admission-policy-contract.ts +5 -2
- package/node_modules/@vellumai/gateway-client/src/index.ts +9 -0
- package/node_modules/@vellumai/gateway-client/src/route-trust-class.ts +94 -0
- package/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +2 -0
- package/node_modules/@vellumai/service-contracts/src/channels.ts +8 -4
- package/openapi.yaml +153 -5
- package/package.json +1 -1
- package/src/__tests__/channel-policy.test.ts +7 -0
- package/src/__tests__/conversation-routes-disk-view.test.ts +141 -2
- package/src/__tests__/identity-routes.test.ts +1 -0
- package/src/__tests__/list-messages-client-message-id.test.ts +43 -0
- package/src/__tests__/openai-provider.test.ts +27 -0
- package/src/__tests__/skill-execute-input.test.ts +11 -0
- package/src/__tests__/tool-approval-handler.test.ts +4 -0
- package/src/__tests__/tool-preview-lifecycle.test.ts +26 -0
- package/src/agent/loop-unparseable-tool-retry.test.ts +333 -0
- package/src/agent/loop.ts +133 -12
- package/src/calls/__tests__/call-controller.test.ts +273 -1
- package/src/calls/__tests__/media-stream-server-integration.test.ts +40 -0
- package/src/calls/__tests__/media-stream-stt-session.test.ts +33 -0
- package/src/calls/call-constants.ts +10 -0
- package/src/calls/call-controller.ts +359 -25
- package/src/calls/media-stream-server.ts +18 -0
- package/src/calls/media-stream-stt-session.ts +30 -9
- package/src/channels/config.ts +8 -0
- package/src/cli/lib/bundled-marketplace.json +39 -0
- package/src/config/composer-preferences.test.ts +97 -0
- package/src/config/composer-preferences.ts +95 -0
- package/src/config/feature-flag-registry.json +16 -8
- package/src/config/schemas/llm.ts +1 -0
- package/src/contacts/types.ts +1 -0
- package/src/daemon/conversation-agent-loop-handlers.ts +18 -2
- package/src/daemon/conversation-agent-loop.ts +3 -0
- package/src/daemon/conversation-messaging.ts +4 -0
- package/src/daemon/conversation-process.ts +15 -0
- package/src/daemon/conversation-queue-manager.ts +2 -0
- package/src/daemon/conversation.ts +2 -0
- package/src/daemon/handlers/shared.ts +13 -2
- package/src/daemon/message-types/sync.ts +1 -0
- package/src/daemon/process-message.ts +5 -0
- package/src/i18n/__tests__/i18n.test.ts +25 -0
- package/src/i18n/assistant-text.ts +18 -0
- package/src/i18n/index.ts +1 -0
- package/src/i18n/messages.ts +11 -0
- package/src/ipc/routes/__tests__/route-adapter.test.ts +40 -0
- package/src/ipc/routes/route-adapter.ts +9 -0
- package/src/notifications/README.md +1 -1
- package/src/notifications/signal.ts +4 -0
- package/src/persistence/conversation-key-store.ts +12 -11
- package/src/providers/__tests__/unparseable-tool-args.test.ts +25 -0
- package/src/providers/inference/adapter-factory.ts +6 -0
- package/src/providers/ionet/client.test.ts +143 -0
- package/src/providers/ionet/client.ts +37 -0
- package/src/providers/model-catalog.ts +40 -0
- package/src/providers/openai/chat-completions-provider.ts +33 -2
- package/src/providers/retry.ts +10 -4
- package/src/providers/unparseable-tool-args.ts +29 -5
- package/src/runtime/__tests__/http-router-trust.test.ts +360 -0
- package/src/runtime/__tests__/shared-principal-lookup.test.ts +346 -0
- package/src/runtime/auth/__tests__/guard-tests.test.ts +51 -1
- package/src/runtime/auth/__tests__/route-policy.test.ts +85 -1
- package/src/runtime/auth/__tests__/scopes.test.ts +36 -0
- package/src/runtime/auth/route-policy.ts +44 -0
- package/src/runtime/auth/scopes.ts +15 -0
- package/src/runtime/auth/types.ts +1 -0
- package/src/runtime/channel-reply-delivery.ts +2 -0
- package/src/runtime/http-router.ts +61 -1
- package/src/runtime/http-types.ts +3 -0
- package/src/runtime/migrations/__tests__/vbundle-streaming-validator.test.ts +35 -10
- package/src/runtime/migrations/__tests__/vbundle-validator-v1-schema.test.ts +48 -0
- package/src/runtime/migrations/vbundle-builder.ts +19 -3
- package/src/runtime/migrations/vbundle-streaming-importer.ts +14 -19
- package/src/runtime/migrations/vbundle-streaming-validator.ts +14 -7
- package/src/runtime/migrations/vbundle-validator.ts +54 -1
- package/src/runtime/routes/composer-settings-routes.test.ts +71 -0
- package/src/runtime/routes/composer-settings-routes.ts +76 -0
- package/src/runtime/routes/conversation-routes.ts +54 -14
- package/src/runtime/routes/identity-routes.ts +2 -0
- package/src/runtime/routes/inbound-stages/background-dispatch.ts +4 -0
- package/src/runtime/routes/index.ts +2 -0
- package/src/runtime/routes/migration-routes.ts +3 -1
- package/src/runtime/routes/types.ts +4 -1
- package/src/runtime/shared-principal-lookup.ts +235 -0
- package/src/tools/host-terminal/host-shell.ts +4 -2
- package/src/tools/shared/shell-output.test.ts +42 -0
- package/src/tools/shared/shell-output.ts +27 -4
- package/src/tools/skills/execute.ts +10 -7
- package/src/tools/terminal/shell.ts +2 -0
- 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
|
-
:
|
|
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
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
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
|
|
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?: {
|
|
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
|
-
{
|
|
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
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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({
|