@vellumai/assistant 0.7.3 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +29 -28
- package/Dockerfile +1 -0
- package/__tests__/permissions/gateway-threshold-reader.test.ts +236 -9
- package/bun.lock +3 -0
- package/knip.json +1 -0
- package/node_modules/@vellumai/ipc-server-utils/bun.lock +24 -0
- package/node_modules/@vellumai/ipc-server-utils/package.json +18 -0
- package/node_modules/@vellumai/ipc-server-utils/src/index.ts +6 -0
- package/node_modules/@vellumai/ipc-server-utils/src/socket-watchdog.test.ts +430 -0
- package/node_modules/@vellumai/ipc-server-utils/src/socket-watchdog.ts +221 -0
- package/node_modules/@vellumai/ipc-server-utils/tsconfig.json +20 -0
- package/openapi.yaml +22 -4
- package/package.json +3 -1
- package/src/__tests__/annotate-risk-options.test.ts +291 -0
- package/src/__tests__/approval-cascade.test.ts +8 -16
- package/src/__tests__/approval-routes-http.test.ts +6 -0
- package/src/__tests__/auto-analysis-end-to-end.test.ts +12 -25
- package/src/__tests__/call-constants.test.ts +10 -1
- package/src/__tests__/call-controller.test.ts +127 -0
- package/src/__tests__/cli-memory-v2-reembed-skills.test.ts +58 -28
- package/src/__tests__/config-loader-platform-defaults.test.ts +284 -1
- package/src/__tests__/context-search-memory-source.test.ts +3 -26
- package/src/__tests__/context-search-pkb-source.test.ts +12 -6
- package/src/__tests__/conversation-abort-tool-results.test.ts +1 -6
- package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +1 -1
- package/src/__tests__/conversation-agent-loop-overflow.test.ts +1 -1
- package/src/__tests__/conversation-agent-loop.test.ts +3 -3
- package/src/__tests__/conversation-confirmation-signals.test.ts +5 -13
- package/src/__tests__/conversation-init.benchmark.test.ts +1 -1
- package/src/__tests__/conversation-process-callsite.test.ts +1 -6
- package/src/__tests__/conversation-provider-retry-repair.test.ts +1 -6
- package/src/__tests__/conversation-runtime-assembly.test.ts +15 -6
- package/src/__tests__/conversation-slash-unknown.test.ts +1 -6
- package/src/__tests__/conversation-surfaces-action-delivery.test.ts +170 -9
- package/src/__tests__/conversation-surfaces-data-persist.test.ts +73 -1
- package/src/__tests__/conversation-tool-setup-app-refresh.test.ts +59 -0
- package/src/__tests__/conversation-workspace-injection.test.ts +1 -7
- package/src/__tests__/conversation-workspace-tool-tracking.test.ts +1 -7
- package/src/__tests__/filing-service.test.ts +2 -19
- package/src/__tests__/handlers-skills-memory-v2-reseed.test.ts +10 -26
- package/src/__tests__/injector-chain.test.ts +24 -16
- package/src/__tests__/injector-pkb-v2-silenced.test.ts +10 -7
- package/src/__tests__/lifecycle-memory-v2-seed.test.ts +154 -67
- package/src/__tests__/notification-decision-fallback.test.ts +91 -0
- package/src/__tests__/notification-decision-strategy.test.ts +22 -0
- package/src/__tests__/oauth-cli.test.ts +121 -0
- package/src/__tests__/relay-server.test.ts +46 -2
- package/src/__tests__/secret-prompt-log-hygiene.test.ts +7 -5
- package/src/__tests__/secret-prompter-channel-fallback.test.ts +7 -5
- package/src/__tests__/secret-response-routing.test.ts +7 -5
- package/src/__tests__/server-history-render.test.ts +82 -0
- package/src/__tests__/skill-include-graph.test.ts +31 -0
- package/src/__tests__/skill-load-tool.test.ts +44 -16
- package/src/__tests__/skills.test.ts +39 -0
- package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +0 -42
- package/src/__tests__/tool-executor.test.ts +155 -0
- package/src/__tests__/voice-session-bridge.test.ts +3 -0
- package/src/__tests__/workspace-migration-069-seed-onboarding-threads.test.ts +120 -0
- package/src/__tests__/workspace-migration-071-remove-safe-storage-release-note.test.ts +206 -0
- package/src/__tests__/workspace-migration-safe-storage-limits-release.test.ts +15 -27
- package/src/agent/loop.ts +11 -0
- package/src/approvals/guardian-decision-primitive.ts +0 -13
- package/src/approvals/guardian-request-resolvers.ts +4 -32
- package/src/calls/call-constants.ts +5 -8
- package/src/calls/call-controller.ts +130 -67
- package/src/calls/relay-server.ts +7 -1
- package/src/calls/voice-session-bridge.ts +1 -1
- package/src/cli/commands/memory-v2.ts +7 -7
- package/src/cli/commands/oauth/__tests__/connect.test.ts +0 -254
- package/src/cli/commands/oauth/connect.ts +10 -52
- package/src/config/bundled-skills/app-builder/SKILL.md +1 -3
- package/src/config/feature-flag-registry.json +1 -17
- package/src/config/loader.ts +72 -19
- package/src/config/schemas/memory-v2.ts +1 -1
- package/src/daemon/__tests__/conversation-lifecycle-auto-analyze.test.ts +32 -0
- package/src/daemon/conversation-agent-loop-handlers.ts +32 -0
- package/src/daemon/conversation-agent-loop.ts +13 -10
- package/src/daemon/conversation-lifecycle.ts +22 -8
- package/src/daemon/conversation-surfaces.ts +16 -14
- package/src/daemon/conversation-tool-setup.ts +9 -5
- package/src/daemon/conversation.ts +1 -1
- package/src/daemon/handlers/shared.ts +26 -0
- package/src/daemon/host-bash-proxy.ts +1 -1
- package/src/daemon/host-browser-proxy.ts +1 -1
- package/src/daemon/host-cu-proxy.ts +1 -1
- package/src/daemon/host-file-proxy.ts +1 -1
- package/src/daemon/host-transfer-proxy.ts +2 -2
- package/src/daemon/lifecycle.ts +88 -73
- package/src/daemon/memory-v2-startup.ts +55 -14
- package/src/daemon/message-types/messages.ts +19 -1
- package/src/documents/document-store.ts +35 -1
- package/src/filing/filing-service.ts +2 -3
- package/src/heartbeat/heartbeat-service.ts +1 -1
- package/src/ipc/assistant-server.ts +93 -36
- package/src/ipc/skill-server.ts +99 -42
- package/src/memory/__tests__/jobs-worker-v2-schedule.test.ts +10 -57
- package/src/memory/context-search/sources/memory-v2.ts +1 -17
- package/src/memory/context-search/sources/memory.ts +2 -2
- package/src/memory/context-search/sources/pkb.ts +2 -3
- package/src/memory/graph/__tests__/conversation-graph-memory-v2-routing.test.ts +104 -61
- package/src/memory/graph/__tests__/handle-remember-v2.test.ts +11 -26
- package/src/memory/graph/conversation-graph-memory.ts +32 -9
- package/src/memory/graph/graph-search.test.ts +6 -5
- package/src/memory/graph/graph-search.ts +3 -4
- package/src/memory/graph/retriever.test.ts +12 -7
- package/src/memory/graph/retriever.ts +4 -5
- package/src/memory/graph/tool-handlers.ts +3 -4
- package/src/memory/graph/tools.ts +4 -4
- package/src/memory/indexer.ts +1 -2
- package/src/memory/jobs/__tests__/embed-concept-page.test.ts +116 -0
- package/src/memory/jobs/embed-concept-page.ts +223 -87
- package/src/memory/jobs-worker.ts +8 -4
- package/src/memory/pkb/pkb-search.test.ts +6 -5
- package/src/memory/pkb/pkb-search.ts +4 -5
- package/src/memory/qdrant-client.ts +3 -0
- package/src/memory/search/semantic.ts +4 -5
- package/src/memory/v2/__tests__/activation.test.ts +35 -5
- package/src/memory/v2/__tests__/consolidation-job.test.ts +21 -32
- package/src/memory/v2/__tests__/injection.test.ts +140 -23
- package/src/memory/v2/__tests__/qdrant.test.ts +310 -9
- package/src/memory/v2/__tests__/sim.test.ts +118 -7
- package/src/memory/v2/__tests__/static-context.test.ts +1 -13
- package/src/memory/v2/__tests__/sweep-job.test.ts +19 -33
- package/src/memory/v2/consolidation-job.ts +7 -8
- package/src/memory/v2/injection.ts +32 -12
- package/src/memory/v2/page-store.ts +39 -0
- package/src/memory/v2/prompts/consolidation.ts +5 -0
- package/src/memory/v2/qdrant.ts +209 -48
- package/src/memory/v2/sim.ts +67 -26
- package/src/memory/v2/static-context.ts +4 -8
- package/src/memory/v2/sweep-job.ts +5 -6
- package/src/memory/v2/types.ts +7 -0
- package/src/notifications/copy-composer.ts +46 -12
- package/src/notifications/decision-engine.ts +46 -0
- package/src/permissions/gateway-threshold-reader.ts +116 -8
- package/src/permissions/prompter.ts +86 -96
- package/src/permissions/secret-prompter.ts +31 -31
- package/src/plugins/defaults/injectors.ts +1 -2
- package/src/proactive-artifact/job.test.ts +51 -4
- package/src/proactive-artifact/job.ts +16 -2
- package/src/proactive-artifact/message-copy.ts +18 -1
- package/src/prompts/templates/SOUL.md +13 -28
- package/src/runtime/auth/route-policy.ts +1 -0
- package/src/runtime/channel-approvals.ts +3 -2
- package/src/runtime/guardian-reply-router.ts +0 -10
- package/src/runtime/pending-interactions.ts +19 -15
- package/src/runtime/routes/__tests__/memory-v2-routes.test.ts +147 -0
- package/src/runtime/routes/approval-routes.ts +7 -3
- package/src/runtime/routes/consolidation-routes.ts +8 -9
- package/src/runtime/routes/conversation-query-routes.ts +44 -1
- package/src/runtime/routes/debug-bash-routes.ts +2 -0
- package/src/runtime/routes/filing-routes.ts +2 -3
- package/src/runtime/routes/inbound-stages/guardian-reply-intercept.ts +0 -3
- package/src/runtime/routes/memory-item-routes.test.ts +3 -9
- package/src/runtime/routes/memory-item-routes.ts +5 -6
- package/src/runtime/routes/memory-v2-routes.ts +103 -17
- package/src/skills/include-graph.ts +35 -13
- package/src/tools/document/document-tool.ts +20 -0
- package/src/tools/executor.ts +18 -2
- package/src/tools/memory/register.test.ts +7 -5
- package/src/tools/permission-checker.ts +15 -0
- package/src/tools/skills/load.ts +24 -20
- package/src/tools/tool-name-aliases.ts +19 -0
- package/src/tools/types.ts +19 -1
- package/src/workspace/migrations/067-release-notes-safe-storage-limits.ts +4 -62
- package/src/workspace/migrations/069-seed-onboarding-threads.ts +28 -0
- package/src/workspace/migrations/070-memory-v2-summary-schema-rebuild.ts +31 -0
- package/src/workspace/migrations/071-remove-safe-storage-release-note.ts +111 -0
- package/src/workspace/migrations/registry.ts +6 -0
|
@@ -32,6 +32,51 @@ export function nonEmpty(value: string | undefined): string | undefined {
|
|
|
32
32
|
return trimmed.length > 0 ? trimmed : undefined;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
export function looksLikeIntermediaryInstruction(text: string): boolean {
|
|
36
|
+
const normalized = text.replace(/\s+/g, " ").trim();
|
|
37
|
+
const intermediaryAction =
|
|
38
|
+
"(?:tell|telling|ask|asking|remind|reminding|nudge|nudging|prompt|prompting|notify|notifying|encourage|encouraging|prime|priming|brief|briefing|coach|coaching)";
|
|
39
|
+
const target = "(?:the\\s+)?(?:guardian|recipient|user)";
|
|
40
|
+
return (
|
|
41
|
+
/\b(?:assistant|agent|system|model|watcher)\s+(?:should|needs?\s+to|must|can|could)\b/i.test(
|
|
42
|
+
normalized,
|
|
43
|
+
) ||
|
|
44
|
+
new RegExp(
|
|
45
|
+
`\\b(?:consider|try|please)\\s+${intermediaryAction}\\s+${target}\\b`,
|
|
46
|
+
"i",
|
|
47
|
+
).test(normalized) ||
|
|
48
|
+
new RegExp(
|
|
49
|
+
`\\b${intermediaryAction}\\s+${target}\\s+(?:to|that|about|with)\\b`,
|
|
50
|
+
"i",
|
|
51
|
+
).test(normalized) ||
|
|
52
|
+
new RegExp(
|
|
53
|
+
`\\b${target}\\s+(?:should|needs?\\s+to|must|might\\s+want\\s+to)\\b`,
|
|
54
|
+
"i",
|
|
55
|
+
).test(normalized) ||
|
|
56
|
+
new RegExp(`\\b(?:for|to)\\s+${target}\\s+to\\b`, "i").test(normalized)
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function buildHeartbeatAlertCopy(
|
|
61
|
+
payload: Record<string, unknown>,
|
|
62
|
+
): RenderedChannelCopy {
|
|
63
|
+
const summary = str(
|
|
64
|
+
payload.summary,
|
|
65
|
+
str(payload.body, "Your assistant found something worth your attention."),
|
|
66
|
+
).trim();
|
|
67
|
+
const safePopupBody = looksLikeIntermediaryInstruction(summary)
|
|
68
|
+
? "I found something worth your attention in a heartbeat check. Open the conversation for details."
|
|
69
|
+
: summary;
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
title: str(payload.title, "Heartbeat Alert"),
|
|
73
|
+
body: safePopupBody,
|
|
74
|
+
deliveryText: safePopupBody,
|
|
75
|
+
conversationTitle: str(payload.conversationTitle, "Heartbeat"),
|
|
76
|
+
conversationSeedMessage: summary,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
35
80
|
// ── Access-request copy contract ─────────────────────────────────────────────
|
|
36
81
|
//
|
|
37
82
|
// Deterministic helpers for building guardian-facing access-request copy.
|
|
@@ -505,18 +550,7 @@ const TEMPLATES: Partial<Record<NotificationSourceEventName, CopyTemplate>> = {
|
|
|
505
550
|
body: str(payload.body, "A watcher event requires your attention"),
|
|
506
551
|
}),
|
|
507
552
|
|
|
508
|
-
"heartbeat.alert":
|
|
509
|
-
const body = str(
|
|
510
|
-
payload.summary,
|
|
511
|
-
str(payload.body, "Your assistant found something worth your attention."),
|
|
512
|
-
);
|
|
513
|
-
return {
|
|
514
|
-
title: str(payload.title, "Heartbeat Alert"),
|
|
515
|
-
body,
|
|
516
|
-
conversationTitle: str(payload.conversationTitle, "Heartbeat"),
|
|
517
|
-
conversationSeedMessage: body,
|
|
518
|
-
};
|
|
519
|
-
},
|
|
553
|
+
"heartbeat.alert": buildHeartbeatAlertCopy,
|
|
520
554
|
|
|
521
555
|
"tool_confirmation.required_action": (payload) => ({
|
|
522
556
|
title: "Tool Confirmation",
|
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
composeFallbackCopy,
|
|
36
36
|
hasAccessRequestInstructions,
|
|
37
37
|
hasInviteFlowDirective,
|
|
38
|
+
looksLikeIntermediaryInstruction,
|
|
38
39
|
} from "./copy-composer.js";
|
|
39
40
|
import { createDecision } from "./decisions-store.js";
|
|
40
41
|
import {
|
|
@@ -127,11 +128,13 @@ function buildSystemPrompt(
|
|
|
127
128
|
``,
|
|
128
129
|
`Copy guidelines (three distinct outputs):`,
|
|
129
130
|
`- \`title\` and \`body\` are for native notification popups (e.g. vellum desktop/mobile) — keep them short and glanceable (title ≤ 8 words, body ≤ 2 sentences).`,
|
|
131
|
+
` - Write popup copy as final copy for the guardian or recipient. Do not write instructions for the assistant or another intermediary.`,
|
|
130
132
|
`- \`deliveryText\` is the channel-native message for chat channels (e.g. telegram). It must read naturally as a standalone message.`,
|
|
131
133
|
` - Do not prepend mechanical labels like "Conversation:".`,
|
|
132
134
|
` - Do not mention channel or transport names (e.g. Telegram, Slack, email) unless the event context explicitly requires it.`,
|
|
133
135
|
` - Do not repeat title/body verbatim unless that repetition is truly necessary.`,
|
|
134
136
|
` - Avoid meta-send phrasing (e.g. "I'd like to send a notification", "May I go ahead with that?"). Write the recipient-facing message directly.`,
|
|
137
|
+
` - Avoid intermediary-instruction phrasing like "consider telling the guardian", "ask the recipient to", or "the assistant should remind them". Rewrite it as final copy the recipient can act on directly.`,
|
|
135
138
|
` - For telegram: 1-2 concise sentences.`,
|
|
136
139
|
`- \`conversationSeedMessage\` is the opening message in the internal notification conversation — it can be richer and more contextual.`,
|
|
137
140
|
` - For vellum (desktop): 2-4 short sentences with useful context and clear next step if action is required.`,
|
|
@@ -664,6 +667,47 @@ function enforceAccessRequestInstructions(
|
|
|
664
667
|
};
|
|
665
668
|
}
|
|
666
669
|
|
|
670
|
+
function enforceHeartbeatAlertCopy(
|
|
671
|
+
decision: NotificationDecision,
|
|
672
|
+
signal: NotificationSignal,
|
|
673
|
+
): NotificationDecision {
|
|
674
|
+
if (signal.sourceEventName !== "heartbeat.alert") return decision;
|
|
675
|
+
if (!decision.shouldNotify || decision.selectedChannels.length === 0)
|
|
676
|
+
return decision;
|
|
677
|
+
|
|
678
|
+
const fallbackCopy = composeFallbackCopy(signal, decision.selectedChannels);
|
|
679
|
+
const nextCopy: Partial<Record<NotificationChannel, RenderedChannelCopy>> = {
|
|
680
|
+
...decision.renderedCopy,
|
|
681
|
+
};
|
|
682
|
+
|
|
683
|
+
for (const channel of decision.selectedChannels) {
|
|
684
|
+
const currentCopy = nextCopy[channel];
|
|
685
|
+
if (
|
|
686
|
+
currentCopy &&
|
|
687
|
+
!heartbeatCopyLooksLikeIntermediaryInstruction(currentCopy)
|
|
688
|
+
) {
|
|
689
|
+
continue;
|
|
690
|
+
}
|
|
691
|
+
const safeCopy = fallbackCopy[channel];
|
|
692
|
+
if (!safeCopy) continue;
|
|
693
|
+
nextCopy[channel] = safeCopy;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
return {
|
|
697
|
+
...decision,
|
|
698
|
+
renderedCopy: nextCopy,
|
|
699
|
+
};
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function heartbeatCopyLooksLikeIntermediaryInstruction(
|
|
703
|
+
copy: RenderedChannelCopy,
|
|
704
|
+
): boolean {
|
|
705
|
+
return [copy.title, copy.body, copy.deliveryText].some(
|
|
706
|
+
(value) =>
|
|
707
|
+
typeof value === "string" && looksLikeIntermediaryInstruction(value),
|
|
708
|
+
);
|
|
709
|
+
}
|
|
710
|
+
|
|
667
711
|
function ensureAccessRequestInstructionsInCopy(
|
|
668
712
|
copy: RenderedChannelCopy,
|
|
669
713
|
requestCode: string,
|
|
@@ -754,6 +798,7 @@ export async function evaluateSignal(
|
|
|
754
798
|
let decision = buildFallbackDecision(signal, availableChannels);
|
|
755
799
|
decision = enforceGuardianRequestCode(decision, signal);
|
|
756
800
|
decision = enforceAccessRequestInstructions(decision, signal);
|
|
801
|
+
decision = enforceHeartbeatAlertCopy(decision, signal);
|
|
757
802
|
decision = enforceGuardianCallConversationAffinity(decision, signal);
|
|
758
803
|
decision = enforceConversationAffinity(
|
|
759
804
|
decision,
|
|
@@ -783,6 +828,7 @@ export async function evaluateSignal(
|
|
|
783
828
|
|
|
784
829
|
decision = enforceGuardianRequestCode(decision, signal);
|
|
785
830
|
decision = enforceAccessRequestInstructions(decision, signal);
|
|
831
|
+
decision = enforceHeartbeatAlertCopy(decision, signal);
|
|
786
832
|
decision = enforceGuardianCallConversationAffinity(decision, signal);
|
|
787
833
|
decision = enforceConversationAffinity(
|
|
788
834
|
decision,
|
|
@@ -44,6 +44,106 @@ const conversationThresholdCache = new Map<
|
|
|
44
44
|
>();
|
|
45
45
|
const CONVERSATION_CACHE_TTL_MS = 5_000;
|
|
46
46
|
|
|
47
|
+
// ── Failure-coalescing log helper ────────────────────────────────────────────
|
|
48
|
+
// When the gateway IPC socket is broken (e.g. the path was unlinked from
|
|
49
|
+
// disk), every threshold lookup fails with ENOENT on the hot path. Without
|
|
50
|
+
// coalescing the per-call WARN drowns the actual signal ("Strict-when-
|
|
51
|
+
// Relaxed because the gateway lost its socket") in its own log spam.
|
|
52
|
+
//
|
|
53
|
+
// Each `op` (e.g. "conversation_threshold", "global_thresholds") emits at
|
|
54
|
+
// most one WARN per {@link DEFAULT_FAILURE_WARN_INTERVAL_MS} window. The
|
|
55
|
+
// first failure in a streak WARNs immediately so failures aren't lost. When
|
|
56
|
+
// the IPC starts working again, an INFO records the streak duration and
|
|
57
|
+
// how many calls were swallowed — that's the cue dashboards should alert
|
|
58
|
+
// on.
|
|
59
|
+
|
|
60
|
+
interface FailureState {
|
|
61
|
+
consecutiveFailures: number;
|
|
62
|
+
firstFailureAt: number;
|
|
63
|
+
lastWarnAt: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const DEFAULT_FAILURE_WARN_INTERVAL_MS = 30_000;
|
|
67
|
+
let failureWarnIntervalMs = DEFAULT_FAILURE_WARN_INTERVAL_MS;
|
|
68
|
+
const failureStateByOp = new Map<string, FailureState>();
|
|
69
|
+
|
|
70
|
+
function noteFailure(
|
|
71
|
+
op: string,
|
|
72
|
+
fields: Record<string, unknown>,
|
|
73
|
+
message: string,
|
|
74
|
+
): void {
|
|
75
|
+
const now = Date.now();
|
|
76
|
+
const state = failureStateByOp.get(op);
|
|
77
|
+
if (!state) {
|
|
78
|
+
failureStateByOp.set(op, {
|
|
79
|
+
consecutiveFailures: 1,
|
|
80
|
+
firstFailureAt: now,
|
|
81
|
+
lastWarnAt: now,
|
|
82
|
+
});
|
|
83
|
+
log.warn(
|
|
84
|
+
{
|
|
85
|
+
...fields,
|
|
86
|
+
op,
|
|
87
|
+
consecutiveFailures: 1,
|
|
88
|
+
event: "ipc_threshold_failure",
|
|
89
|
+
},
|
|
90
|
+
message,
|
|
91
|
+
);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
state.consecutiveFailures += 1;
|
|
95
|
+
if (now - state.lastWarnAt >= failureWarnIntervalMs) {
|
|
96
|
+
log.warn(
|
|
97
|
+
{
|
|
98
|
+
...fields,
|
|
99
|
+
op,
|
|
100
|
+
consecutiveFailures: state.consecutiveFailures,
|
|
101
|
+
streakDurationMs: now - state.firstFailureAt,
|
|
102
|
+
event: "ipc_threshold_failure",
|
|
103
|
+
},
|
|
104
|
+
message,
|
|
105
|
+
);
|
|
106
|
+
state.lastWarnAt = now;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function noteSuccess(op: string): void {
|
|
111
|
+
const state = failureStateByOp.get(op);
|
|
112
|
+
if (!state) return;
|
|
113
|
+
log.info(
|
|
114
|
+
{
|
|
115
|
+
op,
|
|
116
|
+
swallowedFailures: state.consecutiveFailures,
|
|
117
|
+
streakDurationMs: Date.now() - state.firstFailureAt,
|
|
118
|
+
event: "ipc_threshold_recovered",
|
|
119
|
+
},
|
|
120
|
+
"Gateway IPC threshold call recovered after failure streak",
|
|
121
|
+
);
|
|
122
|
+
failureStateByOp.delete(op);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Test-only: clear the failure-coalescing state. */
|
|
126
|
+
export function _resetFailureCoalesceForTesting(): void {
|
|
127
|
+
failureStateByOp.clear();
|
|
128
|
+
failureWarnIntervalMs = DEFAULT_FAILURE_WARN_INTERVAL_MS;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Test-only: read a snapshot of the failure-coalescing state for a given
|
|
133
|
+
* op. Returns `undefined` when no streak is in progress.
|
|
134
|
+
*/
|
|
135
|
+
export function _getFailureStateForTesting(
|
|
136
|
+
op: string,
|
|
137
|
+
): Readonly<FailureState> | undefined {
|
|
138
|
+
const state = failureStateByOp.get(op);
|
|
139
|
+
return state ? { ...state } : undefined;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Test-only: override the WARN cadence. Pass {@link DEFAULT_FAILURE_WARN_INTERVAL_MS} to reset. */
|
|
143
|
+
export function _setFailureWarnIntervalForTesting(intervalMs: number): void {
|
|
144
|
+
failureWarnIntervalMs = intervalMs;
|
|
145
|
+
}
|
|
146
|
+
|
|
47
147
|
/**
|
|
48
148
|
* Clear the global threshold cache. Exported for testing.
|
|
49
149
|
*/
|
|
@@ -112,18 +212,24 @@ export async function getAutoApproveThreshold(
|
|
|
112
212
|
})) as ConversationThreshold | null | undefined;
|
|
113
213
|
|
|
114
214
|
if (result === undefined) {
|
|
115
|
-
|
|
215
|
+
noteFailure(
|
|
216
|
+
"conversation_threshold",
|
|
116
217
|
{ conversationId },
|
|
117
218
|
"IPC call failed for conversation threshold override, falling through to global",
|
|
118
219
|
);
|
|
119
220
|
// Fall through to global threshold fetch below.
|
|
120
|
-
} else if (result && isValidThreshold(result.threshold)) {
|
|
121
|
-
conversationThresholdCache.set(conversationId, {
|
|
122
|
-
threshold: result.threshold,
|
|
123
|
-
timestamp: Date.now(),
|
|
124
|
-
});
|
|
125
|
-
return result.threshold;
|
|
126
221
|
} else {
|
|
222
|
+
// Any defined response (including a null "no override") is a
|
|
223
|
+
// successful round-trip — clear any in-progress failure streak so
|
|
224
|
+
// dashboards see the recovery.
|
|
225
|
+
noteSuccess("conversation_threshold");
|
|
226
|
+
if (result && isValidThreshold(result.threshold)) {
|
|
227
|
+
conversationThresholdCache.set(conversationId, {
|
|
228
|
+
threshold: result.threshold,
|
|
229
|
+
timestamp: Date.now(),
|
|
230
|
+
});
|
|
231
|
+
return result.threshold;
|
|
232
|
+
}
|
|
127
233
|
// result === null (or an unexpected shape) — cache the negative result
|
|
128
234
|
// and fall through to global defaults.
|
|
129
235
|
conversationThresholdCache.set(conversationId, {
|
|
@@ -151,7 +257,8 @@ export async function getAutoApproveThreshold(
|
|
|
151
257
|
} catch (err) {
|
|
152
258
|
// Gateway unreachable — default to "none" (Strict) so no tools are
|
|
153
259
|
// silently auto-approved when the gateway is down.
|
|
154
|
-
|
|
260
|
+
noteFailure(
|
|
261
|
+
"global_thresholds",
|
|
155
262
|
{ error: String(err) },
|
|
156
263
|
"Failed to fetch global thresholds, defaulting to none",
|
|
157
264
|
);
|
|
@@ -176,6 +283,7 @@ async function fetchGlobalThresholds(): Promise<GlobalThresholds> {
|
|
|
176
283
|
throw new Error("Gateway IPC returned no result for global thresholds");
|
|
177
284
|
}
|
|
178
285
|
|
|
286
|
+
noteSuccess("global_thresholds");
|
|
179
287
|
cachedGlobalThresholds = result;
|
|
180
288
|
cachedGlobalTimestamp = Date.now();
|
|
181
289
|
return result;
|
|
@@ -11,19 +11,14 @@ import type { AllowlistOption, ScopeOption, UserDecision } from "./types.js";
|
|
|
11
11
|
|
|
12
12
|
const log = getLogger("permission-prompter");
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
}) => void;
|
|
23
|
-
reject: (reason: Error) => void;
|
|
24
|
-
timer: ReturnType<typeof setTimeout>;
|
|
25
|
-
toolUseId?: string;
|
|
26
|
-
}
|
|
14
|
+
type ConfirmResult = {
|
|
15
|
+
decision: UserDecision;
|
|
16
|
+
selectedPattern?: string;
|
|
17
|
+
selectedScope?: string;
|
|
18
|
+
decisionContext?: string;
|
|
19
|
+
wasTimeout?: boolean;
|
|
20
|
+
wasSystemCancel?: boolean;
|
|
21
|
+
};
|
|
27
22
|
|
|
28
23
|
export type ConfirmationStateCallback = (
|
|
29
24
|
requestId: string,
|
|
@@ -33,7 +28,13 @@ export type ConfirmationStateCallback = (
|
|
|
33
28
|
) => void;
|
|
34
29
|
|
|
35
30
|
export class PermissionPrompter {
|
|
36
|
-
|
|
31
|
+
/**
|
|
32
|
+
* Tracks which requestIds belong to this prompter instance so that
|
|
33
|
+
* denyAllPending / dispose can scope their cleanup to this conversation.
|
|
34
|
+
* The full per-request state (callbacks, timer, toolUseId) lives in
|
|
35
|
+
* pendingInteractions, matching the host proxy pattern.
|
|
36
|
+
*/
|
|
37
|
+
private ownedIds = new Set<string>();
|
|
37
38
|
private sendToClient: (msg: ServerMessage) => void;
|
|
38
39
|
private onStateChanged?: ConfirmationStateCallback;
|
|
39
40
|
|
|
@@ -69,74 +70,68 @@ export class PermissionPrompter {
|
|
|
69
70
|
riskReason?: string,
|
|
70
71
|
isContainerized?: boolean,
|
|
71
72
|
directoryScopeOptions?: readonly { scope: string; label: string }[],
|
|
72
|
-
): Promise<{
|
|
73
|
-
decision: UserDecision;
|
|
74
|
-
selectedPattern?: string;
|
|
75
|
-
selectedScope?: string;
|
|
76
|
-
decisionContext?: string;
|
|
77
|
-
wasTimeout?: boolean;
|
|
78
|
-
wasSystemCancel?: boolean;
|
|
79
|
-
wasAbort?: boolean;
|
|
80
|
-
}> {
|
|
73
|
+
): Promise<ConfirmResult & { wasAbort?: boolean }> {
|
|
81
74
|
if (signal?.aborted) return { decision: "deny", wasAbort: true };
|
|
82
75
|
|
|
83
76
|
const requestId = uuid();
|
|
84
77
|
|
|
85
|
-
// Self-register in pendingInteractions so /v1/confirm can route the
|
|
86
|
-
// response to this conversation without going through broadcastMessage.
|
|
87
|
-
if (conversationId) {
|
|
88
|
-
pendingInteractions.register(requestId, {
|
|
89
|
-
conversationId,
|
|
90
|
-
kind: "confirmation",
|
|
91
|
-
confirmationDetails: {
|
|
92
|
-
toolName,
|
|
93
|
-
input: redactSensitiveFields(input),
|
|
94
|
-
riskLevel,
|
|
95
|
-
executionTarget,
|
|
96
|
-
allowlistOptions: allowlistOptions.map((o) => ({
|
|
97
|
-
label: o.label,
|
|
98
|
-
description: o.description,
|
|
99
|
-
pattern: o.pattern,
|
|
100
|
-
})),
|
|
101
|
-
scopeOptions: scopeOptions.map((o) => ({
|
|
102
|
-
label: o.label,
|
|
103
|
-
scope: o.scope,
|
|
104
|
-
})),
|
|
105
|
-
persistentDecisionsAllowed: persistentDecisionsAllowed ?? true,
|
|
106
|
-
},
|
|
107
|
-
});
|
|
108
|
-
}
|
|
109
|
-
|
|
110
78
|
return new Promise((resolve, reject) => {
|
|
111
79
|
const timeoutMs = getConfig().timeouts.permissionTimeoutSec * 1000;
|
|
80
|
+
|
|
112
81
|
const timer = setTimeout(() => {
|
|
113
|
-
|
|
114
|
-
|
|
82
|
+
const interaction = pendingInteractions.resolve(requestId);
|
|
83
|
+
this.ownedIds.delete(requestId);
|
|
115
84
|
log.warn(
|
|
116
85
|
{ requestId, toolName },
|
|
117
86
|
"Permission prompt timed out, defaulting to deny",
|
|
118
87
|
);
|
|
119
88
|
this.onStateChanged?.(requestId, "timed_out", "timeout", toolUseId);
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
89
|
+
(interaction?.rpcResolve as ((v: ConfirmResult) => void) | undefined)?.(
|
|
90
|
+
{
|
|
91
|
+
decision: "deny",
|
|
92
|
+
wasTimeout: true,
|
|
93
|
+
decisionContext: `The permission prompt for the "${toolName}" tool timed out. The user did not explicitly deny this request — they may have been away or busy. You may retry this tool call if it is still needed for the current task.`,
|
|
94
|
+
},
|
|
95
|
+
);
|
|
125
96
|
}, timeoutMs);
|
|
126
97
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
98
|
+
// Register all lifecycle state in pendingInteractions — same pattern as
|
|
99
|
+
// host proxies. The prompter tracks ownership via ownedIds.
|
|
100
|
+
// Always register unconditionally so rpcResolve/rpcReject/timer
|
|
101
|
+
// are reachable by resolveConfirmation, denyAllPending, and the timeout
|
|
102
|
+
// handler even when conversationId is absent. Routes return 404 for
|
|
103
|
+
// interactions with an empty conversationId, which is correct behaviour.
|
|
104
|
+
pendingInteractions.register(requestId, {
|
|
105
|
+
conversationId: conversationId ?? "",
|
|
106
|
+
kind: "confirmation",
|
|
107
|
+
confirmationDetails: {
|
|
108
|
+
toolName,
|
|
109
|
+
input: redactSensitiveFields(input),
|
|
110
|
+
riskLevel,
|
|
111
|
+
executionTarget,
|
|
112
|
+
allowlistOptions: allowlistOptions.map((o) => ({
|
|
113
|
+
label: o.label,
|
|
114
|
+
description: o.description,
|
|
115
|
+
pattern: o.pattern,
|
|
116
|
+
})),
|
|
117
|
+
scopeOptions: scopeOptions.map((o) => ({
|
|
118
|
+
label: o.label,
|
|
119
|
+
scope: o.scope,
|
|
120
|
+
})),
|
|
121
|
+
persistentDecisionsAllowed: persistentDecisionsAllowed ?? true,
|
|
122
|
+
},
|
|
123
|
+
rpcResolve: resolve as (value: unknown) => void,
|
|
124
|
+
rpcReject: reject,
|
|
125
|
+
timer,
|
|
126
|
+
toolUseId,
|
|
127
|
+
});
|
|
128
|
+
this.ownedIds.add(requestId);
|
|
133
129
|
|
|
134
130
|
if (signal) {
|
|
135
131
|
const onAbort = () => {
|
|
136
|
-
if (this.
|
|
137
|
-
clearTimeout(timer);
|
|
138
|
-
this.pending.delete(requestId);
|
|
132
|
+
if (this.ownedIds.has(requestId)) {
|
|
139
133
|
pendingInteractions.resolve(requestId);
|
|
134
|
+
this.ownedIds.delete(requestId);
|
|
140
135
|
resolve({ decision: "deny", wasAbort: true });
|
|
141
136
|
}
|
|
142
137
|
};
|
|
@@ -175,17 +170,17 @@ export class PermissionPrompter {
|
|
|
175
170
|
}
|
|
176
171
|
|
|
177
172
|
hasPendingRequest(requestId: string): boolean {
|
|
178
|
-
return this.
|
|
173
|
+
return this.ownedIds.has(requestId);
|
|
179
174
|
}
|
|
180
175
|
|
|
181
176
|
/** Returns all currently pending request IDs. */
|
|
182
177
|
getPendingRequestIds(): string[] {
|
|
183
|
-
return [...this.
|
|
178
|
+
return [...this.ownedIds];
|
|
184
179
|
}
|
|
185
180
|
|
|
186
181
|
/** Returns the toolUseId associated with a pending request, if any. */
|
|
187
182
|
getToolUseId(requestId: string): string | undefined {
|
|
188
|
-
return
|
|
183
|
+
return pendingInteractions.get(requestId)?.toolUseId;
|
|
189
184
|
}
|
|
190
185
|
|
|
191
186
|
resolveConfirmation(
|
|
@@ -195,22 +190,17 @@ export class PermissionPrompter {
|
|
|
195
190
|
selectedScope?: string,
|
|
196
191
|
decisionContext?: string,
|
|
197
192
|
): void {
|
|
198
|
-
|
|
199
|
-
if (!pending) {
|
|
193
|
+
if (!this.ownedIds.has(requestId)) {
|
|
200
194
|
log.warn({ requestId }, "No pending prompt for confirmation response");
|
|
201
195
|
return;
|
|
202
196
|
}
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
selectedPattern,
|
|
211
|
-
selectedScope,
|
|
212
|
-
decisionContext,
|
|
213
|
-
});
|
|
197
|
+
// The prompter owns deregistration; all callers use get() to peek before
|
|
198
|
+
// routing to resolveConfirmation, which fires the rpcResolve callback.
|
|
199
|
+
const interaction = pendingInteractions.resolve(requestId);
|
|
200
|
+
this.ownedIds.delete(requestId);
|
|
201
|
+
(interaction?.rpcResolve as ((v: ConfirmResult) => void) | undefined)?.(
|
|
202
|
+
{ decision, selectedPattern, selectedScope, decisionContext },
|
|
203
|
+
);
|
|
214
204
|
}
|
|
215
205
|
|
|
216
206
|
/**
|
|
@@ -219,31 +209,31 @@ export class PermissionPrompter {
|
|
|
219
209
|
* see the denial and can re-request if still needed.
|
|
220
210
|
*/
|
|
221
211
|
denyAllPending(): void {
|
|
222
|
-
for (const
|
|
223
|
-
|
|
224
|
-
this.
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
212
|
+
for (const requestId of [...this.ownedIds]) {
|
|
213
|
+
const interaction = pendingInteractions.resolve(requestId);
|
|
214
|
+
this.ownedIds.delete(requestId);
|
|
215
|
+
(interaction?.rpcResolve as ((v: ConfirmResult) => void) | undefined)?.(
|
|
216
|
+
{
|
|
217
|
+
decision: "deny",
|
|
218
|
+
wasSystemCancel: true,
|
|
219
|
+
decisionContext:
|
|
220
|
+
"The user sent a new message instead of responding to this permission prompt. Stop what you are doing and respond to the user's new message. Do NOT retry this tool or request permission again until the user asks you to.",
|
|
221
|
+
},
|
|
222
|
+
);
|
|
232
223
|
}
|
|
233
224
|
}
|
|
234
225
|
|
|
235
226
|
get hasPending(): boolean {
|
|
236
|
-
return this.
|
|
227
|
+
return this.ownedIds.size > 0;
|
|
237
228
|
}
|
|
238
229
|
|
|
239
230
|
dispose(): void {
|
|
240
|
-
for (const
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
231
|
+
for (const requestId of [...this.ownedIds]) {
|
|
232
|
+
const interaction = pendingInteractions.resolve(requestId);
|
|
233
|
+
this.ownedIds.delete(requestId);
|
|
234
|
+
interaction?.rpcReject?.(
|
|
244
235
|
new AssistantError("Prompter disposed", ErrorCode.INTERNAL_ERROR),
|
|
245
236
|
);
|
|
246
237
|
}
|
|
247
|
-
this.pending.clear();
|
|
248
238
|
}
|
|
249
239
|
}
|