@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
@@ -14,7 +14,9 @@
14
14
  */
15
15
 
16
16
  import { safeStringSlice } from "../util/unicode.js";
17
+
17
18
  const UNPARSEABLE_TOOL_ARGS_KEY = "_raw";
19
+ const UNPARSEABLE_TOOL_ARGS_PREVIEW_LIMIT = 200;
18
20
 
19
21
  /**
20
22
  * Message emitted by the Anthropic SDK's stream accumulator when a tool_use
@@ -30,6 +32,32 @@ export function wrapUnparseableToolArgs(raw: string): Record<string, unknown> {
30
32
  return { [UNPARSEABLE_TOOL_ARGS_KEY]: raw };
31
33
  }
32
34
 
35
+ /**
36
+ * Bound an unparseable marker before it joins history, events, or audit logs.
37
+ * The prefix is enough to diagnose where JSON broke without retaining a
38
+ * multi-megabyte partial tool call in every downstream copy.
39
+ */
40
+ export function boundUnparseableToolArgs(
41
+ input: Record<string, unknown>,
42
+ ): Record<string, unknown> {
43
+ if (!isUnparseableToolArgs(input)) {
44
+ return input;
45
+ }
46
+ const raw = input[UNPARSEABLE_TOOL_ARGS_KEY];
47
+ const bounded = boundUnparseableToolArgsRaw(raw);
48
+ return bounded === raw ? input : wrapUnparseableToolArgs(bounded);
49
+ }
50
+
51
+ /**
52
+ * Bound raw malformed arguments for diagnostic copies that retain the provider
53
+ * response shape rather than the internal `_raw` marker shape.
54
+ */
55
+ export function boundUnparseableToolArgsRaw(raw: string): string {
56
+ return raw.length > UNPARSEABLE_TOOL_ARGS_PREVIEW_LIMIT
57
+ ? `${safeStringSlice(raw, 0, UNPARSEABLE_TOOL_ARGS_PREVIEW_LIMIT)}…`
58
+ : raw;
59
+ }
60
+
33
61
  /**
34
62
  * Detect input produced by {@link wrapUnparseableToolArgs}: exactly one key,
35
63
  * the marker key, holding the raw argument string. The exact-shape check
@@ -56,11 +84,7 @@ export function unparseableToolArgsMessage(
56
84
  toolName: string,
57
85
  raw: string,
58
86
  ): string {
59
- const PREVIEW_LIMIT = 200;
60
- const preview =
61
- raw.length > PREVIEW_LIMIT
62
- ? `${safeStringSlice(raw, 0, PREVIEW_LIMIT)}…`
63
- : raw;
87
+ const preview = boundUnparseableToolArgsRaw(raw);
64
88
  return (
65
89
  `Error: the arguments for "${toolName}" were not valid JSON — the argument stream was malformed or truncated, so the tool was NOT executed. ` +
66
90
  `Received: ${preview || "(empty)"}\n` +
@@ -0,0 +1,360 @@
1
+ /**
2
+ * The router refuses a contact-role token on every route that does not admit
3
+ * its trust class, with a 404, and leaves every other token untouched.
4
+ *
5
+ * The real route table is served with stub handlers, so each route's own
6
+ * endpoint and policy are what is exercised without running its handler. A
7
+ * probe route that opts into contacts covers the admitted path, which no
8
+ * shipped route takes yet.
9
+ */
10
+
11
+ import {
12
+ afterAll,
13
+ beforeAll,
14
+ beforeEach,
15
+ describe,
16
+ expect,
17
+ mock,
18
+ test,
19
+ } from "bun:test";
20
+
21
+ let authDisabled = false;
22
+ const actualEnv = await import("../../config/env.js");
23
+ mock.module("../../config/env.js", () => ({
24
+ ...actualEnv,
25
+ isHttpAuthDisabled: () => authDisabled,
26
+ }));
27
+
28
+ type Trust = { trustClass: string };
29
+ const resolveSharedPrincipalFresh = mock(
30
+ async (_principalId: string): Promise<Trust> => ({
31
+ trustClass: "trusted_contact",
32
+ }),
33
+ );
34
+ const actualLookup = await import("../shared-principal-lookup.js");
35
+ mock.module("../shared-principal-lookup.js", () => ({
36
+ ...actualLookup,
37
+ resolveSharedPrincipalFresh,
38
+ }));
39
+
40
+ const { isContactTrustClass } = await import("../trust-class.js");
41
+ const { CONTACT_ALLOWED, enforcePolicy } =
42
+ await import("../auth/route-policy.js");
43
+ const actualRoutes = await import("../routes/index.js");
44
+ type RouteDefinition = (typeof actualRoutes.ROUTES)[number];
45
+
46
+ let reached: string | undefined;
47
+ const stub = (route: RouteDefinition): RouteDefinition => ({
48
+ ...route,
49
+ requireGuardian: false,
50
+ handler: () => {
51
+ reached = route.operationId;
52
+ return { ok: true };
53
+ },
54
+ });
55
+
56
+ const PROBE: RouteDefinition = {
57
+ operationId: "trust_probe",
58
+ endpoint: "trust-probe",
59
+ method: "POST",
60
+ policy: {
61
+ requiredScopes: ["chat.write"],
62
+ allowedPrincipalTypes: ["actor"],
63
+ allowedTrustClasses: CONTACT_ALLOWED,
64
+ },
65
+ handler: () => ({ ok: true }),
66
+ };
67
+
68
+ const CONTACT_ONLY_PROBE: RouteDefinition = {
69
+ ...PROBE,
70
+ operationId: "contact_only_probe",
71
+ endpoint: "contact-only-probe",
72
+ policy: { ...PROBE.policy!, allowedTrustClasses: ["trusted_contact"] },
73
+ };
74
+
75
+ const REAL_ROUTES = actualRoutes.ROUTES.map(stub);
76
+ const GUARDIAN_ROUTES = REAL_ROUTES.filter(
77
+ (route) => !route.policy?.allowedTrustClasses?.some(isContactTrustClass),
78
+ );
79
+ mock.module("../routes/index.js", () => ({
80
+ ...actualRoutes,
81
+ ROUTES: [...REAL_ROUTES, stub(PROBE), stub(CONTACT_ONLY_PROBE)],
82
+ }));
83
+
84
+ const { resolveScopeProfile } = await import("../auth/scopes.js");
85
+ const { HttpRouter } = await import("../http-router.js");
86
+ import type { AuthContext, ScopeProfile } from "../auth/types.js";
87
+
88
+ function context(
89
+ subject: string,
90
+ scopeProfile: ScopeProfile,
91
+ principalType: AuthContext["principalType"] = "actor",
92
+ ): AuthContext {
93
+ const actorPrincipalId =
94
+ principalType === "actor" ? subject.split(":")[2] : undefined;
95
+ return {
96
+ subject,
97
+ principalType,
98
+ assistantId: "self",
99
+ actorPrincipalId,
100
+ scopeProfile,
101
+ scopes: resolveScopeProfile(scopeProfile),
102
+ policyEpoch: 0,
103
+ };
104
+ }
105
+
106
+ const CONTACT = context("actor:self:principal-alice", "contact_client_v1");
107
+ const GUARDIAN = context("actor:self:principal-bob", "actor_client_v1");
108
+ const GATEWAY = context(
109
+ "svc:gateway:self",
110
+ "gateway_service_v1",
111
+ "svc_gateway",
112
+ );
113
+ const LOCAL = context("local:self:conv-xyz", "local_v1", "local");
114
+
115
+ const UUID = "123e4567-e89b-42d3-a456-426614174000";
116
+
117
+ /** A concrete path for a route's endpoint pattern. */
118
+ function concretePath(route: RouteDefinition): string {
119
+ const types = new Map(
120
+ (route.pathParams ?? []).map((param) => [param.name, param.type]),
121
+ );
122
+ return route.endpoint
123
+ .split("/")
124
+ .map((segment) => {
125
+ if (!segment.startsWith(":")) {
126
+ return segment;
127
+ }
128
+ if (segment.endsWith("*")) {
129
+ return "a/b";
130
+ }
131
+ return types.get(segment.slice(1)) === "uuid" ? UUID : "x";
132
+ })
133
+ .join("/");
134
+ }
135
+
136
+ let server: ReturnType<typeof Bun.serve>;
137
+ let router: InstanceType<typeof HttpRouter>;
138
+
139
+ beforeAll(() => {
140
+ server = Bun.serve({ port: 0, fetch: () => new Response("unused") });
141
+ router = new HttpRouter();
142
+ });
143
+
144
+ afterAll(() => {
145
+ server.stop(true);
146
+ });
147
+
148
+ beforeEach(() => {
149
+ authDisabled = false;
150
+ reached = undefined;
151
+ resolveSharedPrincipalFresh.mockReset();
152
+ resolveSharedPrincipalFresh.mockImplementation(async () => ({
153
+ trustClass: "trusted_contact",
154
+ }));
155
+ });
156
+
157
+ async function dispatch(
158
+ method: string,
159
+ endpoint: string,
160
+ authContext: AuthContext,
161
+ ): Promise<Response> {
162
+ const url = new URL(`http://127.0.0.1/v1/${endpoint}`);
163
+ const req = new Request(url, {
164
+ method,
165
+ headers: { "content-type": "application/json" },
166
+ body: method === "GET" || method === "HEAD" ? undefined : "{}",
167
+ });
168
+ const response = await router.dispatch(
169
+ endpoint,
170
+ req,
171
+ url,
172
+ server,
173
+ authContext,
174
+ );
175
+ if (!response) {
176
+ throw new Error(`no route matched ${method} ${endpoint}`);
177
+ }
178
+ return response;
179
+ }
180
+
181
+ describe("contact-role tokens", () => {
182
+ test("POST /v1/messages is a 404", async () => {
183
+ const response = await dispatch("POST", "messages", CONTACT);
184
+
185
+ expect(response.status).toBe(404);
186
+ const body = (await response.json()) as { error: { code: string } };
187
+ expect(body.error.code).toBe("NOT_FOUND");
188
+ expect(reached).toBeUndefined();
189
+ });
190
+
191
+ test("every route not admitting contacts is a 404, without a trust lookup", async () => {
192
+ expect(GUARDIAN_ROUTES.length).toBeGreaterThan(0);
193
+ const leaks: string[] = [];
194
+ for (const route of GUARDIAN_ROUTES) {
195
+ reached = undefined;
196
+ const response = await dispatch(
197
+ route.method,
198
+ concretePath(route),
199
+ CONTACT,
200
+ );
201
+ if (response.status !== 404 || reached !== undefined) {
202
+ leaks.push(`${route.method} ${route.endpoint}: ${response.status}`);
203
+ }
204
+ }
205
+ expect(leaks).toEqual([]);
206
+ expect(resolveSharedPrincipalFresh).not.toHaveBeenCalled();
207
+ });
208
+
209
+ test("a malformed path on a guardian route is a 404, not a 400", async () => {
210
+ const route = GUARDIAN_ROUTES.find(
211
+ (r) => r.endpoint.includes(":") && !r.pathParams?.length,
212
+ )!;
213
+ const path = route.endpoint.replace(/:[^/]+/, "a%zz");
214
+
215
+ expect((await dispatch(route.method, path, CONTACT)).status).toBe(404);
216
+ expect((await dispatch(route.method, path, GUARDIAN)).status).toBe(400);
217
+ });
218
+
219
+ test("an unrecognized profile is trust-checked too", async () => {
220
+ const response = await dispatch(
221
+ "POST",
222
+ "messages",
223
+ context("actor:self:principal-alice", "bogus_v1" as ScopeProfile),
224
+ );
225
+ expect(response.status).toBe(404);
226
+ expect(reached).toBeUndefined();
227
+ });
228
+
229
+ test("refusal holds under the dev auth bypass", async () => {
230
+ authDisabled = true;
231
+ const response = await dispatch("POST", "messages", CONTACT);
232
+ expect(response.status).toBe(404);
233
+ expect(reached).toBeUndefined();
234
+ });
235
+
236
+ test.each(["trusted_contact", "unverified_contact"])(
237
+ "a route admitting contacts serves a %s",
238
+ async (trustClass) => {
239
+ resolveSharedPrincipalFresh.mockImplementation(async () => ({
240
+ trustClass,
241
+ }));
242
+ const response = await dispatch("POST", "trust-probe", CONTACT);
243
+
244
+ expect(response.status).toBe(200);
245
+ expect(reached).toBe("trust_probe");
246
+ expect(resolveSharedPrincipalFresh).toHaveBeenCalledWith(
247
+ "principal-alice",
248
+ );
249
+ },
250
+ );
251
+
252
+ test.each(["unknown", "guardian"])(
253
+ "a route admitting contacts refuses a principal resolving %s",
254
+ async (trustClass) => {
255
+ resolveSharedPrincipalFresh.mockImplementation(async () => ({
256
+ trustClass,
257
+ }));
258
+ const response = await dispatch("POST", "trust-probe", CONTACT);
259
+
260
+ expect(response.status).toBe(404);
261
+ expect(reached).toBeUndefined();
262
+ },
263
+ );
264
+
265
+ test("a revoked contact is refused on its next request", async () => {
266
+ expect((await dispatch("POST", "trust-probe", CONTACT)).status).toBe(200);
267
+
268
+ resolveSharedPrincipalFresh.mockImplementation(async () => ({
269
+ trustClass: "unknown",
270
+ }));
271
+ reached = undefined;
272
+ expect((await dispatch("POST", "trust-probe", CONTACT)).status).toBe(404);
273
+ expect(reached).toBeUndefined();
274
+ expect(resolveSharedPrincipalFresh).toHaveBeenCalledTimes(2);
275
+ });
276
+
277
+ test("a failed trust lookup refuses", async () => {
278
+ resolveSharedPrincipalFresh.mockImplementation(async () => {
279
+ throw new Error("gateway unreachable");
280
+ });
281
+ const response = await dispatch("POST", "trust-probe", CONTACT);
282
+
283
+ expect(response.status).toBe(404);
284
+ expect(reached).toBeUndefined();
285
+ });
286
+
287
+ test("a contact token naming no actor principal refuses without a lookup", async () => {
288
+ const response = await dispatch(
289
+ "POST",
290
+ "trust-probe",
291
+ context("svc:gateway:self", "contact_client_v1", "svc_gateway"),
292
+ );
293
+
294
+ expect(response.status).toBe(404);
295
+ expect(resolveSharedPrincipalFresh).not.toHaveBeenCalled();
296
+ });
297
+ });
298
+
299
+ describe("other tokens", () => {
300
+ test("a guardian token reaches POST /v1/messages", async () => {
301
+ const response = await dispatch("POST", "messages", GUARDIAN);
302
+
303
+ expect(response.status).toBe(202);
304
+ expect(reached).toBe("messages_post");
305
+ });
306
+
307
+ test("a guardian token reaches a route admitting guardians and contacts", async () => {
308
+ const response = await dispatch("POST", "trust-probe", GUARDIAN);
309
+
310
+ expect(response.status).toBe(200);
311
+ expect(reached).toBe("trust_probe");
312
+ expect(resolveSharedPrincipalFresh).not.toHaveBeenCalled();
313
+ });
314
+
315
+ test("a guardian token gets 404 from a contact-only route, without a lookup", async () => {
316
+ const response = await dispatch("POST", "contact-only-probe", GUARDIAN);
317
+
318
+ expect(response.status).toBe(404);
319
+ const body = (await response.json()) as { error: { code: string } };
320
+ expect(body.error.code).toBe("NOT_FOUND");
321
+ expect(reached).toBeUndefined();
322
+ expect(resolveSharedPrincipalFresh).not.toHaveBeenCalled();
323
+ });
324
+
325
+ test("the dev-bypass guardian is refused on a contact-only route too", async () => {
326
+ authDisabled = true;
327
+ const response = await dispatch("POST", "contact-only-probe", GUARDIAN);
328
+ expect(response.status).toBe(404);
329
+ });
330
+
331
+ test("a contact token reaches a contact-only route", async () => {
332
+ const response = await dispatch("POST", "contact-only-probe", CONTACT);
333
+
334
+ expect(response.status).toBe(200);
335
+ expect(reached).toBe("contact_only_probe");
336
+ });
337
+
338
+ test.each([
339
+ ["guardian", GUARDIAN],
340
+ ["svc_gateway", GATEWAY],
341
+ ["local", LOCAL],
342
+ ] as const)(
343
+ "a %s token gets what the scope policy alone decides on every route",
344
+ async (_label, ctx) => {
345
+ const drift: string[] = [];
346
+ for (const route of REAL_ROUTES) {
347
+ reached = undefined;
348
+ const response = await dispatch(route.method, concretePath(route), ctx);
349
+ const denied = enforcePolicy(route.endpoint, route.policy, ctx);
350
+ const expected = denied ? denied.status : route.operationId;
351
+ const actual = reached ?? response.status;
352
+ if (actual !== expected) {
353
+ drift.push(`${route.method} ${route.endpoint}: ${actual}`);
354
+ }
355
+ }
356
+ expect(drift).toEqual([]);
357
+ expect(resolveSharedPrincipalFresh).not.toHaveBeenCalled();
358
+ },
359
+ );
360
+ });