@vellumai/assistant 0.11.2-dev.202608071521.2d2f93e → 0.11.2-dev.202608071620.8afd4b3
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/package.json +1 -1
- package/src/__tests__/tool-grant-request-escalation.test.ts +40 -1
- package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +68 -1
- package/src/approvals/guardian-request-resolvers.ts +6 -3
- package/src/config/schemas/timeouts.ts +1 -1
- package/src/tools/execution-timeout.ts +9 -2
- package/src/tools/tool-approval-handler.ts +45 -4
package/package.json
CHANGED
|
@@ -101,17 +101,20 @@ import {
|
|
|
101
101
|
getRegisteredKinds,
|
|
102
102
|
getResolver,
|
|
103
103
|
} from "../approvals/guardian-request-resolvers.js";
|
|
104
|
+
import { getConfig } from "../config/loader.js";
|
|
104
105
|
import { getDb } from "../persistence/db-connection.js";
|
|
105
106
|
import { getSqlite } from "../persistence/db-connection.js";
|
|
106
107
|
import { initializeDb } from "../persistence/db-init.js";
|
|
107
108
|
import { scopedApprovalGrants } from "../persistence/schema/index.js";
|
|
108
109
|
import {
|
|
110
|
+
TC_GRANT_WAIT_MAX_MS,
|
|
109
111
|
ToolApprovalHandler,
|
|
110
112
|
waitForInlineGrant,
|
|
111
113
|
} from "../tools/tool-approval-handler.js";
|
|
112
114
|
import type { ToolContext } from "../tools/types.js";
|
|
115
|
+
import { setConfig } from "./helpers/set-config.js";
|
|
113
116
|
|
|
114
|
-
/** Short wait config for tests
|
|
117
|
+
/** Short wait config for tests: keeps escalation cases off the real budget. */
|
|
115
118
|
const TEST_INLINE_WAIT_CONFIG = { maxWaitMs: 100, intervalMs: 20 };
|
|
116
119
|
|
|
117
120
|
await initializeDb();
|
|
@@ -490,6 +493,42 @@ describe("inline wait-and-resume", () => {
|
|
|
490
493
|
expect(elapsed).toBeLessThan(500);
|
|
491
494
|
});
|
|
492
495
|
|
|
496
|
+
test("waitForInlineGrant spends timeouts.permissionTimeoutSec when no explicit budget is given", async () => {
|
|
497
|
+
// The guardian's escalation window must track the configured approval
|
|
498
|
+
// budget rather than a compiled-in constant, so an operator raising
|
|
499
|
+
// permissionTimeoutSec actually widens the window a guardian has to
|
|
500
|
+
// answer in. Omitting maxWaitMs is what production does.
|
|
501
|
+
const priorTimeouts = getConfig().timeouts;
|
|
502
|
+
setConfig("timeouts", {
|
|
503
|
+
...priorTimeouts,
|
|
504
|
+
permissionTimeoutSec: 0.15,
|
|
505
|
+
});
|
|
506
|
+
try {
|
|
507
|
+
const req = seedGrantRequest("sha256:configbudget");
|
|
508
|
+
|
|
509
|
+
const start = Date.now();
|
|
510
|
+
const result = await waitForInlineGrant(
|
|
511
|
+
req.id,
|
|
512
|
+
{
|
|
513
|
+
toolName: "bash",
|
|
514
|
+
inputDigest: "sha256:configbudget",
|
|
515
|
+
consumingRequestId: "consume-config-budget",
|
|
516
|
+
},
|
|
517
|
+
{ intervalMs: 20 },
|
|
518
|
+
);
|
|
519
|
+
const elapsed = Date.now() - start;
|
|
520
|
+
|
|
521
|
+
expect(result.outcome).toBe("timeout");
|
|
522
|
+
// Bounded well under TC_GRANT_WAIT_MAX_MS: a regression that ignores
|
|
523
|
+
// config and falls back to the constant blows this ceiling by ~400x.
|
|
524
|
+
expect(elapsed).toBeGreaterThanOrEqual(120);
|
|
525
|
+
expect(elapsed).toBeLessThan(3_000);
|
|
526
|
+
expect(elapsed).toBeLessThan(TC_GRANT_WAIT_MAX_MS);
|
|
527
|
+
} finally {
|
|
528
|
+
setConfig("timeouts", priorTimeouts);
|
|
529
|
+
}
|
|
530
|
+
}, 10_000);
|
|
531
|
+
|
|
493
532
|
test("waitForInlineGrant returns aborted when signal fires during wait", async () => {
|
|
494
533
|
const req = seedGrantRequest("sha256:abortwait");
|
|
495
534
|
|
|
@@ -170,6 +170,7 @@ mock.module("../channels/gateway-guardian-requests.js", () => sim.module);
|
|
|
170
170
|
import { applyGuardianDecision } from "../approvals/guardian-decision-primitive.js";
|
|
171
171
|
import type { ActorContext } from "../approvals/guardian-request-resolvers.js";
|
|
172
172
|
import { getResolver } from "../approvals/guardian-request-resolvers.js";
|
|
173
|
+
import { getConfig } from "../config/loader.js";
|
|
173
174
|
import type { TrustContext } from "../daemon/trust-context-types.js";
|
|
174
175
|
import { getDb } from "../persistence/db-connection.js";
|
|
175
176
|
import { initializeDb } from "../persistence/db-init.js";
|
|
@@ -177,12 +178,14 @@ import { scopedApprovalGrants } from "../persistence/schema/index.js";
|
|
|
177
178
|
import { bridgeConfirmationRequestToGuardian } from "../runtime/confirmation-request-guardian-bridge.js";
|
|
178
179
|
import { resolveRoutingState } from "../runtime/trust-context-resolver.js";
|
|
179
180
|
import {
|
|
181
|
+
resolveInlineGrantWaitMs,
|
|
180
182
|
TC_GRANT_WAIT_MAX_MS,
|
|
181
183
|
ToolApprovalHandler,
|
|
182
184
|
waitForInlineGrant,
|
|
183
185
|
} from "../tools/tool-approval-handler.js";
|
|
184
186
|
import type { ToolContext } from "../tools/types.js";
|
|
185
187
|
import { seedContactChannel } from "./helpers/seed-contact-channel.js";
|
|
188
|
+
import { setConfig } from "./helpers/set-config.js";
|
|
186
189
|
|
|
187
190
|
await initializeDb();
|
|
188
191
|
|
|
@@ -754,7 +757,10 @@ describe("(f) timeout/stale flow: stale guardian decision after inline wait time
|
|
|
754
757
|
test("inline_wait_active staleness guard: expired marker allows retry notification", async () => {
|
|
755
758
|
// Create a guardian request with a stale inline_wait_active marker
|
|
756
759
|
// that simulates a daemon crash during the wait.
|
|
757
|
-
|
|
760
|
+
// Age the marker past the real wait budget, which the resolver's
|
|
761
|
+
// staleness threshold tracks. Deriving it from the same helper the
|
|
762
|
+
// waiter uses keeps this case meaningful if the budget changes.
|
|
763
|
+
const staleTimestamp = Date.now() - resolveInlineGrantWaitMs() - 60_000;
|
|
758
764
|
const req = sim.seedRequest({
|
|
759
765
|
id: `req-stale-${Date.now()}`,
|
|
760
766
|
kind: "tool_grant_request",
|
|
@@ -802,6 +808,67 @@ describe("(f) timeout/stale flow: stale guardian decision after inline wait time
|
|
|
802
808
|
expect(retryNotifications.length).toBeGreaterThan(0);
|
|
803
809
|
});
|
|
804
810
|
|
|
811
|
+
test("inline_wait_active marker older than the fallback constant but inside the real budget still suppresses retry", async () => {
|
|
812
|
+
// The staleness threshold must track the wait budget, not the fallback
|
|
813
|
+
// constant. A marker aged past TC_GRANT_WAIT_MAX_MS + buffer but still
|
|
814
|
+
// well inside the configured budget belongs to a waiter that is very
|
|
815
|
+
// much alive: telling the requester to retry would race a call that is
|
|
816
|
+
// about to resume, and the retry then fails against the one-time grant
|
|
817
|
+
// the live waiter consumes.
|
|
818
|
+
const priorTimeouts = getConfig().timeouts;
|
|
819
|
+
// Seed the budget rather than leaning on the ambient default, so the
|
|
820
|
+
// window this case probes exists no matter what the suite's config holds.
|
|
821
|
+
setConfig("timeouts", { ...priorTimeouts, permissionTimeoutSec: 300 });
|
|
822
|
+
try {
|
|
823
|
+
const budgetMs = resolveInlineGrantWaitMs();
|
|
824
|
+
// Comfortably past the old 90s threshold, comfortably short of the budget.
|
|
825
|
+
const markerAgeMs = TC_GRANT_WAIT_MAX_MS + 45_000;
|
|
826
|
+
const liveTimestamp = Date.now() - markerAgeMs;
|
|
827
|
+
expect(markerAgeMs).toBeLessThan(budgetMs);
|
|
828
|
+
|
|
829
|
+
const req = sim.seedRequest({
|
|
830
|
+
id: `req-live-${Date.now()}`,
|
|
831
|
+
kind: "tool_grant_request",
|
|
832
|
+
sourceType: "channel",
|
|
833
|
+
sourceChannel: "telegram",
|
|
834
|
+
sourceConversationId: "conv-live-1",
|
|
835
|
+
requesterExternalUserId: "requester-1",
|
|
836
|
+
requesterChatId: "requester-chat-1",
|
|
837
|
+
guardianExternalUserId: "guardian-1",
|
|
838
|
+
guardianPrincipalId: "test-principal-id",
|
|
839
|
+
toolName: "bash",
|
|
840
|
+
inputDigest: "sha256:livewait",
|
|
841
|
+
expiresAt: Date.now() + 60_000,
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
await sim.module.updateGuardianRequest(req.id, {
|
|
845
|
+
followupState: `inline_wait_active:${liveTimestamp}`,
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
deliveredReplies.length = 0;
|
|
849
|
+
const approvalResult = await applyGuardianDecision({
|
|
850
|
+
requestId: req.id,
|
|
851
|
+
action: "approve_once",
|
|
852
|
+
actorContext: guardianActor(),
|
|
853
|
+
channelDeliveryContext: {
|
|
854
|
+
replyCallbackUrl: "http://localhost:3000/reply",
|
|
855
|
+
guardianChatId: "guardian-chat-1",
|
|
856
|
+
assistantId: "self",
|
|
857
|
+
},
|
|
858
|
+
});
|
|
859
|
+
expect(approvalResult.applied).toBe(true);
|
|
860
|
+
|
|
861
|
+
const retryNotifications = deliveredReplies.filter(
|
|
862
|
+
(r) =>
|
|
863
|
+
typeof r.payload.text === "string" &&
|
|
864
|
+
(r.payload.text as string).includes("Please retry"),
|
|
865
|
+
);
|
|
866
|
+
expect(retryNotifications.length).toBe(0);
|
|
867
|
+
} finally {
|
|
868
|
+
setConfig("timeouts", priorTimeouts);
|
|
869
|
+
}
|
|
870
|
+
});
|
|
871
|
+
|
|
805
872
|
test("fresh inline_wait_active marker suppresses retry notification", async () => {
|
|
806
873
|
// Create a request with a FRESH inline_wait_active marker
|
|
807
874
|
const freshTimestamp = Date.now();
|
|
@@ -54,7 +54,7 @@ import {
|
|
|
54
54
|
readBatchMetadata,
|
|
55
55
|
resolvePendingQuestion,
|
|
56
56
|
} from "../runtime/question-resolution.js";
|
|
57
|
-
import {
|
|
57
|
+
import { resolveInlineGrantWaitMs } from "../tools/tool-approval-handler.js";
|
|
58
58
|
import { getLogger } from "../util/logger.js";
|
|
59
59
|
import {
|
|
60
60
|
channelCanAddressOneReaderInBand,
|
|
@@ -1587,7 +1587,10 @@ const toolGrantRequestResolver: GuardianRequestResolver = {
|
|
|
1587
1587
|
// outlive the actual waiter if the daemon crashes or restarts during
|
|
1588
1588
|
// the wait. To avoid permanently suppressing the retry notification, we
|
|
1589
1589
|
// treat the marker as stale if the encoded start timestamp is older than
|
|
1590
|
-
// the maximum wait budget plus a 30s buffer.
|
|
1590
|
+
// the maximum wait budget plus a 30s buffer. The budget is read from the
|
|
1591
|
+
// same resolver the waiter itself uses, so a config change moves both
|
|
1592
|
+
// together; sizing this off a constant would let it fall below the real
|
|
1593
|
+
// wait and declare a live waiter dead.
|
|
1591
1594
|
const INLINE_WAIT_STALENESS_BUFFER_MS = 30_000;
|
|
1592
1595
|
const freshRequest = await getGuardianRequestOrNull(request.id);
|
|
1593
1596
|
const followupState = freshRequest?.followupState ?? "";
|
|
@@ -1604,7 +1607,7 @@ const toolGrantRequestResolver: GuardianRequestResolver = {
|
|
|
1604
1607
|
? Date.now() - waitStartMs
|
|
1605
1608
|
: Infinity; // Treat unparseable timestamps as stale for safety.
|
|
1606
1609
|
const stalenessThresholdMs =
|
|
1607
|
-
|
|
1610
|
+
resolveInlineGrantWaitMs() + INLINE_WAIT_STALENESS_BUFFER_MS;
|
|
1608
1611
|
if (markerAgeMs > stalenessThresholdMs) {
|
|
1609
1612
|
log.warn(
|
|
1610
1613
|
{
|
|
@@ -26,7 +26,7 @@ export const TimeoutConfigSchema = z
|
|
|
26
26
|
.positive("timeouts.permissionTimeoutSec must be a positive number")
|
|
27
27
|
.default(300)
|
|
28
28
|
.describe(
|
|
29
|
-
"How long to wait for
|
|
29
|
+
"How long to wait for a human to approve a tool call before timing out (seconds). Spent by both approval paths: the interactive permission prompt, and the inline wait when a contact's sensitive tool call is escalated to the guardian.",
|
|
30
30
|
),
|
|
31
31
|
questionResponseTimeoutSec: z
|
|
32
32
|
.number({ error: "timeouts.questionResponseTimeoutSec must be a number" })
|
|
@@ -6,11 +6,18 @@ const TIMEOUT_SENTINEL = Symbol("tool-timeout");
|
|
|
6
6
|
/**
|
|
7
7
|
* Convert a config-provided seconds value to a safe milliseconds value,
|
|
8
8
|
* falling back to the default if the input is NaN, non-finite, zero, or negative.
|
|
9
|
+
*
|
|
10
|
+
* `fallbackMs` lets callers governed by a different budget (e.g. the inline
|
|
11
|
+
* grant wait) keep their own floor instead of inheriting the tool-execution
|
|
12
|
+
* default.
|
|
9
13
|
*/
|
|
10
|
-
export function safeTimeoutMs(
|
|
14
|
+
export function safeTimeoutMs(
|
|
15
|
+
sec: unknown,
|
|
16
|
+
fallbackMs: number = DEFAULT_TOOL_EXECUTION_TIMEOUT_SEC * 1000,
|
|
17
|
+
): number {
|
|
11
18
|
const n = Number(sec);
|
|
12
19
|
if (!Number.isFinite(n) || n <= 0) {
|
|
13
|
-
return
|
|
20
|
+
return fallbackMs;
|
|
14
21
|
}
|
|
15
22
|
return n * 1000;
|
|
16
23
|
}
|
|
@@ -39,6 +39,7 @@ import { computeToolApprovalDigest } from "../security/tool-approval-digest.js";
|
|
|
39
39
|
import { recordToolDenied, recordToolError } from "../telemetry/tool-audit.js";
|
|
40
40
|
import { getLogger } from "../util/logger.js";
|
|
41
41
|
import { resolveExecutionTarget } from "./execution-target.js";
|
|
42
|
+
import { safeTimeoutMs } from "./execution-timeout.js";
|
|
42
43
|
import { channelCoordinatesFromToolContext } from "./policy-context.js";
|
|
43
44
|
import { getAllTools, getTool, getToolOwner } from "./registry.js";
|
|
44
45
|
import { isSideEffectTool } from "./side-effects.js";
|
|
@@ -127,9 +128,39 @@ export function buildInactiveToolMessage(args: {
|
|
|
127
128
|
|
|
128
129
|
/** Default polling interval for inline grant wait (ms). */
|
|
129
130
|
const TC_GRANT_WAIT_INTERVAL_MS = 500;
|
|
130
|
-
/**
|
|
131
|
+
/**
|
|
132
|
+
* Fallback maximum wait for the inline grant wait (ms), used only when the
|
|
133
|
+
* deployed config cannot be read. The governing budget is
|
|
134
|
+
* `timeouts.permissionTimeoutSec` (see {@link resolveInlineGrantWaitMs}).
|
|
135
|
+
*/
|
|
131
136
|
export const TC_GRANT_WAIT_MAX_MS = 60_000;
|
|
132
137
|
|
|
138
|
+
/**
|
|
139
|
+
* Resolve the wait budget for an escalated tool grant.
|
|
140
|
+
*
|
|
141
|
+
* A guardian answering an escalated tool call is making the same decision as a
|
|
142
|
+
* local user answering a permission prompt, so both paths spend the same
|
|
143
|
+
* budget: `timeouts.permissionTimeoutSec` (read by `permissions/prompter.ts`).
|
|
144
|
+
* If anything, this path needs the larger share of it: the prompter's user
|
|
145
|
+
* already has the prompt on screen, while the guardian is notified
|
|
146
|
+
* out-of-band and has to context-switch before deciding.
|
|
147
|
+
*
|
|
148
|
+
* Falls back to {@link TC_GRANT_WAIT_MAX_MS} rather than the tool-execution
|
|
149
|
+
* default on a non-positive value, so a bad config can never collapse the
|
|
150
|
+
* window to zero and auto-deny every escalation.
|
|
151
|
+
*
|
|
152
|
+
* Exported because the grant resolver sizes its `inline_wait_active` staleness
|
|
153
|
+
* threshold off this same budget: if the two drift, an approval arriving while
|
|
154
|
+
* a waiter is still live gets misread as a dead waiter and the requester is
|
|
155
|
+
* told to retry a call that is about to resume on its own.
|
|
156
|
+
*/
|
|
157
|
+
export function resolveInlineGrantWaitMs(): number {
|
|
158
|
+
return safeTimeoutMs(
|
|
159
|
+
getConfig().timeouts.permissionTimeoutSec,
|
|
160
|
+
TC_GRANT_WAIT_MAX_MS,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
133
164
|
/**
|
|
134
165
|
* Inline wait result for trusted-contact grant polling.
|
|
135
166
|
* - `granted`: a grant was minted and consumed within the wait window.
|
|
@@ -152,13 +183,16 @@ export type InlineGrantWaitOutcome =
|
|
|
152
183
|
* and atomically consume the grant).
|
|
153
184
|
*
|
|
154
185
|
* Only called for trusted_contact actors with valid guardian bindings.
|
|
186
|
+
*
|
|
187
|
+
* `options.maxWaitMs` overrides the wait budget; omitting it spends the
|
|
188
|
+
* configured one from {@link resolveInlineGrantWaitMs}.
|
|
155
189
|
*/
|
|
156
190
|
export async function waitForInlineGrant(
|
|
157
191
|
escalationRequestId: string,
|
|
158
192
|
consumeParams: Parameters<typeof consumeGrantForInvocation>[0],
|
|
159
193
|
options?: { maxWaitMs?: number; intervalMs?: number; signal?: AbortSignal },
|
|
160
194
|
): Promise<InlineGrantWaitOutcome> {
|
|
161
|
-
const maxWait = options?.maxWaitMs ??
|
|
195
|
+
const maxWait = options?.maxWaitMs ?? resolveInlineGrantWaitMs();
|
|
162
196
|
const interval = options?.intervalMs ?? TC_GRANT_WAIT_INTERVAL_MS;
|
|
163
197
|
const signal = options?.signal;
|
|
164
198
|
const deadline = Date.now() + maxWait;
|
|
@@ -592,9 +626,16 @@ export type PreExecutionGateResult =
|
|
|
592
626
|
}
|
|
593
627
|
| { allowed: false; result: ToolExecutionResult };
|
|
594
628
|
|
|
595
|
-
/**
|
|
629
|
+
/**
|
|
630
|
+
* Overrides for the inline grant wait behavior. Production leaves this empty
|
|
631
|
+
* so the wait spends the configured budget; tests inject short waits to keep
|
|
632
|
+
* escalation cases fast.
|
|
633
|
+
*/
|
|
596
634
|
export interface InlineGrantWaitConfig {
|
|
597
|
-
/**
|
|
635
|
+
/**
|
|
636
|
+
* Maximum time to wait for guardian approval (ms). Defaults to the budget
|
|
637
|
+
* from {@link resolveInlineGrantWaitMs}.
|
|
638
|
+
*/
|
|
598
639
|
maxWaitMs?: number;
|
|
599
640
|
/** Polling interval during the wait (ms). Defaults to TC_GRANT_WAIT_INTERVAL_MS. */
|
|
600
641
|
intervalMs?: number;
|