@vellumai/assistant 0.12.4-dev.202609232017.d454a3d → 0.12.4-dev.202609232115.73e8b74
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/docs/architecture/memory.md +9 -1
- package/docs/architecture/scheduling.md +13 -5
- package/docs/guardian-request-flow.md +1 -1
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/guardian-requests.test.ts +26 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/guardian-requests.ts +77 -3
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/guardian-requests.test.ts +26 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/guardian-requests.ts +77 -3
- package/node_modules/@vellumai/gateway-client/src/outbound-contract.ts +2 -4
- package/node_modules/@vellumai/service-contracts/src/__tests__/guardian-requests.test.ts +26 -0
- package/node_modules/@vellumai/service-contracts/src/guardian-requests.ts +77 -3
- package/openapi.yaml +9 -1
- package/package.json +1 -1
- package/src/__tests__/background-shell-bash.test.ts +34 -0
- package/src/__tests__/background-shell-host-bash.test.ts +98 -1
- package/src/__tests__/background-tool-registry.test.ts +39 -0
- package/src/__tests__/client-os-metadata-persistence.test.ts +4 -20
- package/src/__tests__/conversation-agent-loop.test.ts +529 -1
- package/src/__tests__/conversation-pairing.test.ts +59 -0
- package/src/__tests__/conversation-queue.test.ts +257 -4
- package/src/__tests__/conversation-runtime-assembly.test.ts +27 -0
- package/src/__tests__/emit-signal-routing-intent.test.ts +62 -0
- package/src/__tests__/evict-conversations-for-reload.test.ts +21 -0
- package/src/__tests__/introduction-card-resolver.test.ts +53 -8
- package/src/__tests__/non-member-access-request.test.ts +48 -0
- package/src/__tests__/notification-events-source-context-probe.test.ts +48 -1
- package/src/__tests__/notification-platform-adapter.test.ts +97 -0
- package/src/__tests__/notification-vellum-adapter.test.ts +103 -3
- package/src/__tests__/scheduler-disk-pressure.test.ts +1 -0
- package/src/__tests__/scheduler-result-notification.test.ts +521 -7
- package/src/__tests__/scheduler-wake.test.ts +1 -0
- package/src/__tests__/subagent-cron-run-attribution.test.ts +41 -2
- package/src/__tests__/subagent-manager-notify.test.ts +48 -0
- package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +6 -6
- package/src/__tests__/wake-schedule-escalation.test.ts +1 -0
- package/src/api/index.ts +4 -0
- package/src/api/requests/guardian-actions.test.ts +45 -0
- package/src/api/requests/guardian-actions.ts +20 -0
- package/src/approvals/guardian-decision-primitive.ts +6 -4
- package/src/approvals/guardian-request-resolvers.ts +35 -30
- package/src/calls/__tests__/voice-leg-coordinator.test.ts +28 -0
- package/src/calls/__tests__/voice-session-bridge.test.ts +17 -0
- package/src/calls/__tests__/voice-triage-escalate.test.ts +8 -0
- package/src/calls/voice-control-protocol.ts +3 -0
- package/src/calls/voice-leg-coordinator.ts +8 -1
- package/src/calls/voice-session-bridge.ts +13 -0
- package/src/calls/voice-triage-escalate.ts +26 -6
- package/src/daemon/__tests__/abort-null-controller.test.ts +135 -1
- package/src/daemon/__tests__/turn-tail-assistant-reply-notify.test.ts +16 -0
- package/src/daemon/conversation-agent-loop.ts +40 -22
- package/src/daemon/conversation-lifecycle.ts +70 -7
- package/src/daemon/conversation-messaging.ts +31 -2
- package/src/daemon/conversation-process.ts +107 -16
- package/src/daemon/conversation-queue-manager.ts +7 -0
- package/src/daemon/conversation-runtime-assembly.ts +1 -0
- package/src/daemon/conversation-turn-finalize.ts +14 -0
- package/src/daemon/conversation.ts +14 -0
- package/src/daemon/wake-conversation-ops.ts +4 -2
- package/src/live-voice/__tests__/live-voice-triage-escalate.test.ts +11 -0
- package/src/live-voice/live-voice-session.ts +2 -0
- package/src/messaging/providers/discord/withdraw.ts +4 -5
- package/src/messaging/providers/slack/withdraw.ts +5 -5
- package/src/messaging/providers/telegram-bot/withdraw.ts +4 -5
- package/src/notifications/README.md +77 -6
- package/src/notifications/__tests__/assistant-reply-producer.test.ts +377 -119
- package/src/notifications/__tests__/background-result-producer.test.ts +1272 -0
- package/src/notifications/__tests__/broadcaster.test.ts +292 -1
- package/src/notifications/__tests__/completion-policy.test.ts +243 -0
- package/src/notifications/__tests__/decision-engine.test.ts +51 -4
- package/src/notifications/__tests__/has-pending-background-work.test.ts +281 -0
- package/src/notifications/__tests__/home-feed-side-effect.test.ts +74 -0
- package/src/notifications/__tests__/resolve-visible-in-source.test.ts +110 -1
- package/src/notifications/__tests__/schedule-result-producer.test.ts +11 -4
- package/src/notifications/adapters/macos.ts +24 -12
- package/src/notifications/adapters/platform.ts +16 -5
- package/src/notifications/assistant-reply-producer.ts +96 -71
- package/src/notifications/background-result-producer.ts +369 -0
- package/src/notifications/broadcaster.ts +28 -8
- package/src/notifications/completion-policy.ts +115 -0
- package/src/notifications/completion-work.ts +46 -0
- package/src/notifications/conversation-pairing.ts +10 -6
- package/src/notifications/decision-engine.ts +18 -24
- package/src/notifications/emit-signal.ts +16 -1
- package/src/notifications/events-store.ts +4 -0
- package/src/notifications/has-pending-background-work.ts +91 -0
- package/src/notifications/home-feed-mirror.ts +6 -1
- package/src/notifications/home-feed-side-effect.ts +8 -9
- package/src/notifications/resolve-visible-in-source.ts +56 -2
- package/src/notifications/result-output.ts +154 -0
- package/src/notifications/schedule-result-producer.ts +6 -187
- package/src/notifications/types.ts +2 -0
- package/src/persistence/__tests__/recent-conversation-messages.test.ts +41 -0
- package/src/persistence/conversation-crud.ts +24 -0
- package/src/persistence/conversation-types.ts +1 -35
- package/src/plugins/defaults/memory/__tests__/turn-retrieval.test.ts +21 -0
- package/src/plugins/defaults/memory/hooks/user-prompt-submit.ts +7 -3
- package/src/plugins/defaults/memory/turn-retrieval.ts +8 -0
- package/src/plugins/defaults/memory/v3/__tests__/injection.test.ts +24 -0
- package/src/plugins/defaults/memory/v3/injector.ts +4 -4
- package/src/plugins/types.ts +2 -0
- package/src/runtime/__tests__/agent-wake.test.ts +308 -0
- package/src/runtime/access-request-helper.ts +16 -9
- package/src/runtime/agent-wake-queue.ts +52 -0
- package/src/runtime/agent-wake.ts +110 -16
- package/src/runtime/channel-approval-types.ts +6 -38
- package/src/runtime/channel-approvals.ts +4 -5
- package/src/runtime/introduction-policy.test.ts +17 -1
- package/src/runtime/introduction-policy.ts +25 -7
- package/src/runtime/routes/guardian-action-routes.ts +8 -23
- package/src/runtime/web-presence.ts +3 -4
- package/src/schedule/schedule-store.ts +10 -0
- package/src/schedule/scheduler.ts +83 -24
- package/src/subagent/manager.ts +32 -12
- package/src/telemetry/telemetry-wire-source.json +1 -1
- package/src/telemetry/telemetry-wire.generated.ts +1 -0
- package/src/tools/background-tool-registry.ts +30 -3
- package/src/tools/host-terminal/host-shell.ts +7 -0
- package/src/tools/terminal/shell-launch.test.ts +27 -1
- package/src/tools/terminal/shell.ts +4 -0
- package/src/util/abort-reasons.ts +2 -0
- package/src/util/single-flight.test.ts +6 -0
- package/src/util/single-flight.ts +2 -0
|
@@ -214,7 +214,15 @@ static substrate context remain available. When the answer depends on a saved pe
|
|
|
214
214
|
fact that is absent from that context, the front-door rule escalates instead of
|
|
215
215
|
guessing.
|
|
216
216
|
|
|
217
|
-
The escalated leg runs the
|
|
217
|
+
The escalated leg normally runs the memory pipeline before the quality model.
|
|
218
|
+
During screen sharing, the front door can emit `[ESCALATE_SCREEN]` for an
|
|
219
|
+
annotation or immediate on-screen action answerable from the screen and
|
|
220
|
+
current conversation. The bridge skips fresh retrieval only when that verdict
|
|
221
|
+
and an active screen share are both present. This per-turn policy gates the
|
|
222
|
+
legacy hook and both v3 injectors, including re-injection, and clears at turn
|
|
223
|
+
release. Static context and historical memory blocks remain available.
|
|
224
|
+
Requests needing saved facts or preferences absent from context, uncertain
|
|
225
|
+
classifications, and ordinary `[ESCALATE]` verdicts retain retrieval.
|
|
218
226
|
V3 retrieval routes on the latest visible caller message, ignoring the hidden
|
|
219
227
|
continuation message used to start that leg. The selector uses low effort to
|
|
220
228
|
keep retrieval latency bounded. This keeps current-turn memory work off the
|
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
Recurring schedules, watchers, and queued task execution architecture.
|
|
4
4
|
|
|
5
|
+
## Execute-mode completion and time limits
|
|
6
|
+
|
|
7
|
+
An execute-mode run owns its automatic result notification, including results from background commands and subagents. The scheduler waits for the firing's own active turns, queued continuations, commands, child tasks, and command wakes to settle before marking the run successful and emitting `schedule.result`. Unrelated work in a reused conversation does not extend the wait. Ownership is recorded before asynchronous turn setup, covers dequeued messages while their processing claim and persistence are pending, and follows wakes through queueing, hydration, and execution. Dequeued dispatches retain a cancellation signal for teardown until the active turn takes over. User interrupts preserve accepted user prompts during that handoff; started turns remain cancellable. Background work retains its original timing through queueing, dispatch, active processing, and command wakes. The reply-notification gate and persisted-history check apply that timing to suppress stale kickoff alerts without hiding replies to newer user requests. Quiet runs skip this fallback, and explicit successful delivery suppresses duplicates.
|
|
8
|
+
|
|
9
|
+
The wait shares the run's `timeouts.scheduleTurnTimeoutSec` budget. If delegated work exceeds that budget, the scheduler records an error, cancels only commands, child tasks, queued continuations, and the active turn owned by that run. User work and other runs in the same conversation remain intact. The attempt follows the existing retry and exhaustion-alert policy; partial output never emits a success notification. Scheduled command wakes carry `cronRunId`, check the persisted run status before starting, and observe cancellation while running, so a late callback cannot restart a failed attempt.
|
|
10
|
+
|
|
5
11
|
## Recurrence Schedules — Cron and RRULE Dual-Syntax Engine
|
|
6
12
|
|
|
7
13
|
The scheduler supports two recurrence syntaxes for recurring tasks:
|
|
@@ -88,11 +94,13 @@ sequenceDiagram
|
|
|
88
94
|
|
|
89
95
|
The `enforceRoutingIntent()` step runs after the LLM produces a channel selection but before deterministic checks. It acts as a post-decision guard:
|
|
90
96
|
|
|
91
|
-
| Intent | Enforcement Rule
|
|
92
|
-
| ---------------- |
|
|
93
|
-
| `single_channel` |
|
|
94
|
-
| `multi_channel` | If the LLM selected < 2 channels and 2+ are connected, expand to at least two connected channels.
|
|
95
|
-
| `all_channels` | Replace the LLM's selection with all connected channels.
|
|
97
|
+
| Intent | Enforcement Rule |
|
|
98
|
+
| ---------------- | ---------------------------------------------------------------------------------------------------------------------------- |
|
|
99
|
+
| `single_channel` | Cap delivery to one channel: the signal's source channel when it is connected, otherwise the first channel the LLM selected. |
|
|
100
|
+
| `multi_channel` | If the LLM selected < 2 channels and 2+ are connected, expand to at least two connected channels. |
|
|
101
|
+
| `all_channels` | Replace the LLM's selection with all connected channels. |
|
|
102
|
+
|
|
103
|
+
A decision the LLM suppressed (`shouldNotify: false`) is left alone under every intent. A signal with no routing intent keeps the LLM's selection.
|
|
96
104
|
|
|
97
105
|
When enforcement changes the decision, the updated `selectedChannels` and annotated `reasoningSummary` are re-persisted to `notification_decisions` so the audit trail reflects what was actually dispatched.
|
|
98
106
|
|
|
@@ -24,7 +24,7 @@ anti-pattern was retired in #35642 and again in the ask_question redesign).
|
|
|
24
24
|
(generic actions[] + plainTextFallback) resolveQuestionContext)
|
|
25
25
|
│
|
|
26
26
|
▼ per-channel rendering ONLY
|
|
27
|
-
channel adapters (notifications/adapters/{telegram,slack,macos,platform},
|
|
27
|
+
channel adapters (notifications/adapters/{telegram,slack,discord,macos,platform},
|
|
28
28
|
telegram: inline keyboard; slack: blocks; vellum: conversation card via approval-card-builder)
|
|
29
29
|
card deliveries recorded per channel (guardian-delivery-recorder.ts → guardian_request_deliveries)
|
|
30
30
|
│
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
GUARDIAN_DECISION_ACTION_IDS,
|
|
5
|
+
isDenyingGuardianAction,
|
|
6
|
+
isParkGuardianAction,
|
|
7
|
+
} from "../guardian-requests.js";
|
|
8
|
+
|
|
9
|
+
describe("guardian decision actions", () => {
|
|
10
|
+
test("reject, leave_unverified and block deny; every other action approves", () => {
|
|
11
|
+
const denying = GUARDIAN_DECISION_ACTION_IDS.filter(
|
|
12
|
+
isDenyingGuardianAction,
|
|
13
|
+
);
|
|
14
|
+
expect(denying).toEqual(["reject", "leave_unverified", "block"]);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("only leave_unverified parks the sender", () => {
|
|
18
|
+
const parking = GUARDIAN_DECISION_ACTION_IDS.filter(isParkGuardianAction);
|
|
19
|
+
expect(parking).toEqual(["leave_unverified"]);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("an absent or unknown action neither denies nor parks", () => {
|
|
23
|
+
expect(isDenyingGuardianAction(undefined)).toBe(false);
|
|
24
|
+
expect(isParkGuardianAction("approve_always")).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Canonical vocabulary for
|
|
3
|
-
* the
|
|
4
|
-
*
|
|
2
|
+
* Canonical vocabulary for guardian requests, shared by the gateway that owns
|
|
3
|
+
* the request rows, the daemon that decides and projects them, and the web
|
|
4
|
+
* that renders the projection: a request's lifecycle status, the actions a
|
|
5
|
+
* guardian can decide it with, and the weight a card gives each action.
|
|
5
6
|
*/
|
|
6
7
|
import { z } from "zod";
|
|
7
8
|
|
|
@@ -16,3 +17,76 @@ export const GuardianRequestStatusSchema = z.enum(
|
|
|
16
17
|
GUARDIAN_REQUEST_STATUS_VALUES,
|
|
17
18
|
);
|
|
18
19
|
export type GuardianRequestStatus = z.infer<typeof GuardianRequestStatusSchema>;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The actions a guardian can decide a request with.
|
|
23
|
+
*
|
|
24
|
+
* `approve_once` / `reject` are the generic decision pair used by every
|
|
25
|
+
* request kind. `trust` / `verify_code` / `leave_unverified` / `block` are the
|
|
26
|
+
* introduction-card actions, valid only for `access_request` requests.
|
|
27
|
+
*/
|
|
28
|
+
export const GUARDIAN_DECISION_ACTION_IDS = [
|
|
29
|
+
"approve_once",
|
|
30
|
+
"reject",
|
|
31
|
+
"trust",
|
|
32
|
+
"verify_code",
|
|
33
|
+
"leave_unverified",
|
|
34
|
+
"block",
|
|
35
|
+
] as const;
|
|
36
|
+
export const GuardianDecisionActionIdSchema = z.enum(
|
|
37
|
+
GUARDIAN_DECISION_ACTION_IDS,
|
|
38
|
+
);
|
|
39
|
+
export type GuardianDecisionActionId = z.infer<
|
|
40
|
+
typeof GuardianDecisionActionIdSchema
|
|
41
|
+
>;
|
|
42
|
+
|
|
43
|
+
/** Actions that resolve a request to `denied`; every other one approves. */
|
|
44
|
+
export const GUARDIAN_DENYING_ACTION_VALUES = [
|
|
45
|
+
"reject",
|
|
46
|
+
"leave_unverified",
|
|
47
|
+
"block",
|
|
48
|
+
] as const satisfies readonly GuardianDecisionActionId[];
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The denying actions that park the sender at `unverified`: a neutral hold,
|
|
52
|
+
* not a rejection. A parked contact is still admitted under the permissive
|
|
53
|
+
* admission floors (`any_contact`, `strangers`) and can be trusted or verified
|
|
54
|
+
* later; contrast `block` (revoked, a hard keep-out) and `reject` (an explicit
|
|
55
|
+
* decline). All three resolve the request to `denied`, so a resolved card
|
|
56
|
+
* consults this to read a park neutrally rather than as a denial.
|
|
57
|
+
*/
|
|
58
|
+
export const GUARDIAN_PARK_ACTION_VALUES = [
|
|
59
|
+
"leave_unverified",
|
|
60
|
+
] as const satisfies readonly GuardianDecisionActionId[];
|
|
61
|
+
|
|
62
|
+
const DENYING_ACTIONS: ReadonlySet<string> = new Set(
|
|
63
|
+
GUARDIAN_DENYING_ACTION_VALUES,
|
|
64
|
+
);
|
|
65
|
+
const PARK_ACTIONS: ReadonlySet<string> = new Set(GUARDIAN_PARK_ACTION_VALUES);
|
|
66
|
+
|
|
67
|
+
/** True when `action` resolves a request to `denied`. */
|
|
68
|
+
export function isDenyingGuardianAction(action: string | undefined): boolean {
|
|
69
|
+
return action !== undefined && DENYING_ACTIONS.has(action);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** True when `action` parks the sender at `unverified`. */
|
|
73
|
+
export function isParkGuardianAction(action: string | undefined): boolean {
|
|
74
|
+
return action !== undefined && PARK_ACTIONS.has(action);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Surface-agnostic weight of a card action. Each renderer translates it to
|
|
79
|
+
* its own token (Slack primary/danger, a web button variant); absent means the
|
|
80
|
+
* renderer's default.
|
|
81
|
+
*/
|
|
82
|
+
export const GUARDIAN_ACTION_EMPHASIS_VALUES = [
|
|
83
|
+
"primary",
|
|
84
|
+
"secondary",
|
|
85
|
+
"destructive",
|
|
86
|
+
] as const;
|
|
87
|
+
export const GuardianActionEmphasisSchema = z.enum(
|
|
88
|
+
GUARDIAN_ACTION_EMPHASIS_VALUES,
|
|
89
|
+
);
|
|
90
|
+
export type GuardianActionEmphasis = z.infer<
|
|
91
|
+
typeof GuardianActionEmphasisSchema
|
|
92
|
+
>;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
GUARDIAN_DECISION_ACTION_IDS,
|
|
5
|
+
isDenyingGuardianAction,
|
|
6
|
+
isParkGuardianAction,
|
|
7
|
+
} from "../guardian-requests.js";
|
|
8
|
+
|
|
9
|
+
describe("guardian decision actions", () => {
|
|
10
|
+
test("reject, leave_unverified and block deny; every other action approves", () => {
|
|
11
|
+
const denying = GUARDIAN_DECISION_ACTION_IDS.filter(
|
|
12
|
+
isDenyingGuardianAction,
|
|
13
|
+
);
|
|
14
|
+
expect(denying).toEqual(["reject", "leave_unverified", "block"]);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("only leave_unverified parks the sender", () => {
|
|
18
|
+
const parking = GUARDIAN_DECISION_ACTION_IDS.filter(isParkGuardianAction);
|
|
19
|
+
expect(parking).toEqual(["leave_unverified"]);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("an absent or unknown action neither denies nor parks", () => {
|
|
23
|
+
expect(isDenyingGuardianAction(undefined)).toBe(false);
|
|
24
|
+
expect(isParkGuardianAction("approve_always")).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Canonical vocabulary for
|
|
3
|
-
* the
|
|
4
|
-
*
|
|
2
|
+
* Canonical vocabulary for guardian requests, shared by the gateway that owns
|
|
3
|
+
* the request rows, the daemon that decides and projects them, and the web
|
|
4
|
+
* that renders the projection: a request's lifecycle status, the actions a
|
|
5
|
+
* guardian can decide it with, and the weight a card gives each action.
|
|
5
6
|
*/
|
|
6
7
|
import { z } from "zod";
|
|
7
8
|
|
|
@@ -16,3 +17,76 @@ export const GuardianRequestStatusSchema = z.enum(
|
|
|
16
17
|
GUARDIAN_REQUEST_STATUS_VALUES,
|
|
17
18
|
);
|
|
18
19
|
export type GuardianRequestStatus = z.infer<typeof GuardianRequestStatusSchema>;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The actions a guardian can decide a request with.
|
|
23
|
+
*
|
|
24
|
+
* `approve_once` / `reject` are the generic decision pair used by every
|
|
25
|
+
* request kind. `trust` / `verify_code` / `leave_unverified` / `block` are the
|
|
26
|
+
* introduction-card actions, valid only for `access_request` requests.
|
|
27
|
+
*/
|
|
28
|
+
export const GUARDIAN_DECISION_ACTION_IDS = [
|
|
29
|
+
"approve_once",
|
|
30
|
+
"reject",
|
|
31
|
+
"trust",
|
|
32
|
+
"verify_code",
|
|
33
|
+
"leave_unverified",
|
|
34
|
+
"block",
|
|
35
|
+
] as const;
|
|
36
|
+
export const GuardianDecisionActionIdSchema = z.enum(
|
|
37
|
+
GUARDIAN_DECISION_ACTION_IDS,
|
|
38
|
+
);
|
|
39
|
+
export type GuardianDecisionActionId = z.infer<
|
|
40
|
+
typeof GuardianDecisionActionIdSchema
|
|
41
|
+
>;
|
|
42
|
+
|
|
43
|
+
/** Actions that resolve a request to `denied`; every other one approves. */
|
|
44
|
+
export const GUARDIAN_DENYING_ACTION_VALUES = [
|
|
45
|
+
"reject",
|
|
46
|
+
"leave_unverified",
|
|
47
|
+
"block",
|
|
48
|
+
] as const satisfies readonly GuardianDecisionActionId[];
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The denying actions that park the sender at `unverified`: a neutral hold,
|
|
52
|
+
* not a rejection. A parked contact is still admitted under the permissive
|
|
53
|
+
* admission floors (`any_contact`, `strangers`) and can be trusted or verified
|
|
54
|
+
* later; contrast `block` (revoked, a hard keep-out) and `reject` (an explicit
|
|
55
|
+
* decline). All three resolve the request to `denied`, so a resolved card
|
|
56
|
+
* consults this to read a park neutrally rather than as a denial.
|
|
57
|
+
*/
|
|
58
|
+
export const GUARDIAN_PARK_ACTION_VALUES = [
|
|
59
|
+
"leave_unverified",
|
|
60
|
+
] as const satisfies readonly GuardianDecisionActionId[];
|
|
61
|
+
|
|
62
|
+
const DENYING_ACTIONS: ReadonlySet<string> = new Set(
|
|
63
|
+
GUARDIAN_DENYING_ACTION_VALUES,
|
|
64
|
+
);
|
|
65
|
+
const PARK_ACTIONS: ReadonlySet<string> = new Set(GUARDIAN_PARK_ACTION_VALUES);
|
|
66
|
+
|
|
67
|
+
/** True when `action` resolves a request to `denied`. */
|
|
68
|
+
export function isDenyingGuardianAction(action: string | undefined): boolean {
|
|
69
|
+
return action !== undefined && DENYING_ACTIONS.has(action);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** True when `action` parks the sender at `unverified`. */
|
|
73
|
+
export function isParkGuardianAction(action: string | undefined): boolean {
|
|
74
|
+
return action !== undefined && PARK_ACTIONS.has(action);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Surface-agnostic weight of a card action. Each renderer translates it to
|
|
79
|
+
* its own token (Slack primary/danger, a web button variant); absent means the
|
|
80
|
+
* renderer's default.
|
|
81
|
+
*/
|
|
82
|
+
export const GUARDIAN_ACTION_EMPHASIS_VALUES = [
|
|
83
|
+
"primary",
|
|
84
|
+
"secondary",
|
|
85
|
+
"destructive",
|
|
86
|
+
] as const;
|
|
87
|
+
export const GuardianActionEmphasisSchema = z.enum(
|
|
88
|
+
GUARDIAN_ACTION_EMPHASIS_VALUES,
|
|
89
|
+
);
|
|
90
|
+
export type GuardianActionEmphasis = z.infer<
|
|
91
|
+
typeof GuardianActionEmphasisSchema
|
|
92
|
+
>;
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import type { KnownBlock } from "@slack/types";
|
|
18
|
+
import { GuardianActionEmphasisSchema } from "@vellumai/service-contracts/guardian-requests";
|
|
18
19
|
import { z } from "zod";
|
|
19
20
|
|
|
20
21
|
// ---------------------------------------------------------------------------
|
|
@@ -41,10 +42,7 @@ export type AttachmentMetadata = z.infer<typeof AttachmentMetadataSchema>;
|
|
|
41
42
|
export const ApprovalActionOptionSchema = z.object({
|
|
42
43
|
id: z.string(),
|
|
43
44
|
label: z.string(),
|
|
44
|
-
|
|
45
|
-
// token (Slack primary/danger, Surface primary/destructive); absent means
|
|
46
|
-
// the renderer applies its own default styling.
|
|
47
|
-
emphasis: z.enum(["primary", "secondary", "destructive"]).optional(),
|
|
45
|
+
emphasis: GuardianActionEmphasisSchema.optional(),
|
|
48
46
|
});
|
|
49
47
|
|
|
50
48
|
export type ApprovalActionOption = z.infer<typeof ApprovalActionOptionSchema>;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
GUARDIAN_DECISION_ACTION_IDS,
|
|
5
|
+
isDenyingGuardianAction,
|
|
6
|
+
isParkGuardianAction,
|
|
7
|
+
} from "../guardian-requests.js";
|
|
8
|
+
|
|
9
|
+
describe("guardian decision actions", () => {
|
|
10
|
+
test("reject, leave_unverified and block deny; every other action approves", () => {
|
|
11
|
+
const denying = GUARDIAN_DECISION_ACTION_IDS.filter(
|
|
12
|
+
isDenyingGuardianAction,
|
|
13
|
+
);
|
|
14
|
+
expect(denying).toEqual(["reject", "leave_unverified", "block"]);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("only leave_unverified parks the sender", () => {
|
|
18
|
+
const parking = GUARDIAN_DECISION_ACTION_IDS.filter(isParkGuardianAction);
|
|
19
|
+
expect(parking).toEqual(["leave_unverified"]);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("an absent or unknown action neither denies nor parks", () => {
|
|
23
|
+
expect(isDenyingGuardianAction(undefined)).toBe(false);
|
|
24
|
+
expect(isParkGuardianAction("approve_always")).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Canonical vocabulary for
|
|
3
|
-
* the
|
|
4
|
-
*
|
|
2
|
+
* Canonical vocabulary for guardian requests, shared by the gateway that owns
|
|
3
|
+
* the request rows, the daemon that decides and projects them, and the web
|
|
4
|
+
* that renders the projection: a request's lifecycle status, the actions a
|
|
5
|
+
* guardian can decide it with, and the weight a card gives each action.
|
|
5
6
|
*/
|
|
6
7
|
import { z } from "zod";
|
|
7
8
|
|
|
@@ -16,3 +17,76 @@ export const GuardianRequestStatusSchema = z.enum(
|
|
|
16
17
|
GUARDIAN_REQUEST_STATUS_VALUES,
|
|
17
18
|
);
|
|
18
19
|
export type GuardianRequestStatus = z.infer<typeof GuardianRequestStatusSchema>;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The actions a guardian can decide a request with.
|
|
23
|
+
*
|
|
24
|
+
* `approve_once` / `reject` are the generic decision pair used by every
|
|
25
|
+
* request kind. `trust` / `verify_code` / `leave_unverified` / `block` are the
|
|
26
|
+
* introduction-card actions, valid only for `access_request` requests.
|
|
27
|
+
*/
|
|
28
|
+
export const GUARDIAN_DECISION_ACTION_IDS = [
|
|
29
|
+
"approve_once",
|
|
30
|
+
"reject",
|
|
31
|
+
"trust",
|
|
32
|
+
"verify_code",
|
|
33
|
+
"leave_unverified",
|
|
34
|
+
"block",
|
|
35
|
+
] as const;
|
|
36
|
+
export const GuardianDecisionActionIdSchema = z.enum(
|
|
37
|
+
GUARDIAN_DECISION_ACTION_IDS,
|
|
38
|
+
);
|
|
39
|
+
export type GuardianDecisionActionId = z.infer<
|
|
40
|
+
typeof GuardianDecisionActionIdSchema
|
|
41
|
+
>;
|
|
42
|
+
|
|
43
|
+
/** Actions that resolve a request to `denied`; every other one approves. */
|
|
44
|
+
export const GUARDIAN_DENYING_ACTION_VALUES = [
|
|
45
|
+
"reject",
|
|
46
|
+
"leave_unverified",
|
|
47
|
+
"block",
|
|
48
|
+
] as const satisfies readonly GuardianDecisionActionId[];
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The denying actions that park the sender at `unverified`: a neutral hold,
|
|
52
|
+
* not a rejection. A parked contact is still admitted under the permissive
|
|
53
|
+
* admission floors (`any_contact`, `strangers`) and can be trusted or verified
|
|
54
|
+
* later; contrast `block` (revoked, a hard keep-out) and `reject` (an explicit
|
|
55
|
+
* decline). All three resolve the request to `denied`, so a resolved card
|
|
56
|
+
* consults this to read a park neutrally rather than as a denial.
|
|
57
|
+
*/
|
|
58
|
+
export const GUARDIAN_PARK_ACTION_VALUES = [
|
|
59
|
+
"leave_unverified",
|
|
60
|
+
] as const satisfies readonly GuardianDecisionActionId[];
|
|
61
|
+
|
|
62
|
+
const DENYING_ACTIONS: ReadonlySet<string> = new Set(
|
|
63
|
+
GUARDIAN_DENYING_ACTION_VALUES,
|
|
64
|
+
);
|
|
65
|
+
const PARK_ACTIONS: ReadonlySet<string> = new Set(GUARDIAN_PARK_ACTION_VALUES);
|
|
66
|
+
|
|
67
|
+
/** True when `action` resolves a request to `denied`. */
|
|
68
|
+
export function isDenyingGuardianAction(action: string | undefined): boolean {
|
|
69
|
+
return action !== undefined && DENYING_ACTIONS.has(action);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** True when `action` parks the sender at `unverified`. */
|
|
73
|
+
export function isParkGuardianAction(action: string | undefined): boolean {
|
|
74
|
+
return action !== undefined && PARK_ACTIONS.has(action);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Surface-agnostic weight of a card action. Each renderer translates it to
|
|
79
|
+
* its own token (Slack primary/danger, a web button variant); absent means the
|
|
80
|
+
* renderer's default.
|
|
81
|
+
*/
|
|
82
|
+
export const GUARDIAN_ACTION_EMPHASIS_VALUES = [
|
|
83
|
+
"primary",
|
|
84
|
+
"secondary",
|
|
85
|
+
"destructive",
|
|
86
|
+
] as const;
|
|
87
|
+
export const GuardianActionEmphasisSchema = z.enum(
|
|
88
|
+
GUARDIAN_ACTION_EMPHASIS_VALUES,
|
|
89
|
+
);
|
|
90
|
+
export type GuardianActionEmphasis = z.infer<
|
|
91
|
+
typeof GuardianActionEmphasisSchema
|
|
92
|
+
>;
|
package/openapi.yaml
CHANGED
|
@@ -14106,7 +14106,7 @@ paths:
|
|
|
14106
14106
|
post:
|
|
14107
14107
|
operationId: guardianactions_decision_post
|
|
14108
14108
|
summary: Submit guardian decision
|
|
14109
|
-
description: Submit a guardian
|
|
14109
|
+
description: Submit a guardian decision on a pending request.
|
|
14110
14110
|
tags:
|
|
14111
14111
|
- guardian
|
|
14112
14112
|
requestBody:
|
|
@@ -14118,9 +14118,17 @@ paths:
|
|
|
14118
14118
|
properties:
|
|
14119
14119
|
requestId:
|
|
14120
14120
|
type: string
|
|
14121
|
+
minLength: 1
|
|
14121
14122
|
description: Guardian request ID
|
|
14122
14123
|
action:
|
|
14123
14124
|
type: string
|
|
14125
|
+
enum:
|
|
14126
|
+
- approve_once
|
|
14127
|
+
- reject
|
|
14128
|
+
- trust
|
|
14129
|
+
- verify_code
|
|
14130
|
+
- leave_unverified
|
|
14131
|
+
- block
|
|
14124
14132
|
description: Decision action
|
|
14125
14133
|
conversationId:
|
|
14126
14134
|
type: string
|
package/package.json
CHANGED
|
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
|
2
2
|
|
|
3
3
|
import type { WakeOptions } from "../runtime/agent-wake.js";
|
|
4
4
|
import type { BackgroundTool } from "../tools/background-tool-registry.js";
|
|
5
|
+
import type { ToolContext } from "../tools/types.js";
|
|
5
6
|
|
|
6
7
|
// ── Mock modules ────────────────────────────────────────────────────────────
|
|
7
8
|
|
|
@@ -239,4 +240,37 @@ describe("bash tool background mode", () => {
|
|
|
239
240
|
expect(mockRegisterBackgroundTool).not.toHaveBeenCalled();
|
|
240
241
|
expect(mockWakeAgentForOpportunity).not.toHaveBeenCalled();
|
|
241
242
|
});
|
|
243
|
+
|
|
244
|
+
for (const cronRunId of [undefined, null, "cron-run-123"]) {
|
|
245
|
+
for (const outcome of ["completed", "failed", "cancelled"] as const) {
|
|
246
|
+
test(`preserves originating schedule ${String(cronRunId)} on ${outcome}`, async () => {
|
|
247
|
+
const context: ToolContext = {
|
|
248
|
+
...baseContext,
|
|
249
|
+
cronRunId,
|
|
250
|
+
};
|
|
251
|
+
await shellTool.execute(
|
|
252
|
+
{
|
|
253
|
+
command:
|
|
254
|
+
outcome === "cancelled"
|
|
255
|
+
? "sleep 30"
|
|
256
|
+
: `sleep 0.1; exit ${outcome === "failed" ? "1" : "0"}`,
|
|
257
|
+
activity: "test",
|
|
258
|
+
background: true,
|
|
259
|
+
},
|
|
260
|
+
context,
|
|
261
|
+
);
|
|
262
|
+
context.cronRunId = "cron-run-later";
|
|
263
|
+
expect(registeredTools[0]!.cronRunId).toBe(cronRunId ?? undefined);
|
|
264
|
+
if (outcome === "cancelled") {
|
|
265
|
+
registeredTools[0]!.cancel();
|
|
266
|
+
}
|
|
267
|
+
await waitForWake(mockWakeAgentForOpportunity);
|
|
268
|
+
|
|
269
|
+
expect(mockWakeAgentForOpportunity).toHaveBeenCalledTimes(1);
|
|
270
|
+
const wake = mockWakeAgentForOpportunity.mock.calls[0]![0];
|
|
271
|
+
expect(wake.cronRunId).toBe(cronRunId ?? undefined);
|
|
272
|
+
expect(wake.backgroundToolCompletion?.status).toBe(outcome);
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
}
|
|
242
276
|
});
|
|
@@ -84,7 +84,11 @@ mock.module("../daemon/host-bash-proxy.js", () => ({
|
|
|
84
84
|
// Import under test — MUST come after mock.module calls.
|
|
85
85
|
// ---------------------------------------------------------------------------
|
|
86
86
|
|
|
87
|
-
import type {
|
|
87
|
+
import type { WakeOptions } from "../runtime/agent-wake.js";
|
|
88
|
+
import type {
|
|
89
|
+
BackgroundTool,
|
|
90
|
+
CompletedBackgroundTool,
|
|
91
|
+
} from "../tools/background-tool-registry.js";
|
|
88
92
|
import { hostShellTool } from "../tools/host-terminal/host-shell.js";
|
|
89
93
|
import type { ToolContext, ToolExecutionResult } from "../tools/types.js";
|
|
90
94
|
|
|
@@ -148,6 +152,56 @@ afterEach(() => {
|
|
|
148
152
|
// ---------------------------------------------------------------------------
|
|
149
153
|
|
|
150
154
|
describe("host_bash background mode — proxy path", () => {
|
|
155
|
+
for (const cronRunId of [undefined, null, "cron-run-123"]) {
|
|
156
|
+
for (const outcome of [
|
|
157
|
+
"completed",
|
|
158
|
+
"failed",
|
|
159
|
+
"rejected",
|
|
160
|
+
"cancelled",
|
|
161
|
+
"cancelled-rejection",
|
|
162
|
+
] as const) {
|
|
163
|
+
test(`preserves originating schedule ${String(cronRunId)} on ${outcome}`, async () => {
|
|
164
|
+
mockProxyAvailable = true;
|
|
165
|
+
const result = Promise.withResolvers<ToolExecutionResult>();
|
|
166
|
+
mockProxyRequestImpl = () => result.promise;
|
|
167
|
+
const context = makeContext({ cronRunId });
|
|
168
|
+
|
|
169
|
+
await hostShellTool.execute(
|
|
170
|
+
{ command: "echo bg-proxy", background: true },
|
|
171
|
+
context,
|
|
172
|
+
);
|
|
173
|
+
context.cronRunId = "cron-run-later";
|
|
174
|
+
const ownedCommand = (
|
|
175
|
+
mockRegisterBackgroundTool.mock.calls as unknown[][]
|
|
176
|
+
)[0]![0] as BackgroundTool;
|
|
177
|
+
expect(ownedCommand.cronRunId).toBe(cronRunId ?? undefined);
|
|
178
|
+
const cancelled =
|
|
179
|
+
outcome === "cancelled" || outcome === "cancelled-rejection";
|
|
180
|
+
if (cancelled) {
|
|
181
|
+
const registered = (
|
|
182
|
+
mockRegisterBackgroundTool.mock.calls as unknown[][]
|
|
183
|
+
)[0]![0] as BackgroundTool;
|
|
184
|
+
registered.cancel();
|
|
185
|
+
}
|
|
186
|
+
if (outcome === "rejected" || outcome === "cancelled-rejection") {
|
|
187
|
+
result.reject(new Error("proxy transport error"));
|
|
188
|
+
} else {
|
|
189
|
+
result.resolve({ content: "done", isError: outcome === "failed" });
|
|
190
|
+
}
|
|
191
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
192
|
+
|
|
193
|
+
expect(mockWakeAgentForOpportunity).toHaveBeenCalledTimes(1);
|
|
194
|
+
const wake = (
|
|
195
|
+
mockWakeAgentForOpportunity.mock.calls as unknown[][]
|
|
196
|
+
)[0]![0] as WakeOptions;
|
|
197
|
+
expect(wake.cronRunId).toBe(cronRunId ?? undefined);
|
|
198
|
+
expect(wake.backgroundToolCompletion?.status).toBe(
|
|
199
|
+
cancelled ? "cancelled" : outcome === "rejected" ? "failed" : outcome,
|
|
200
|
+
);
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
151
205
|
test("returns immediately with backgrounded response", async () => {
|
|
152
206
|
const proxyResult: ToolExecutionResult = {
|
|
153
207
|
content: "proxy output",
|
|
@@ -311,6 +365,49 @@ describe("host_bash background mode — proxy path", () => {
|
|
|
311
365
|
// ---------------------------------------------------------------------------
|
|
312
366
|
|
|
313
367
|
describe("host_bash background mode — direct execution path", () => {
|
|
368
|
+
for (const cronRunId of [undefined, null, "cron-run-123"]) {
|
|
369
|
+
for (const outcome of [
|
|
370
|
+
"completed",
|
|
371
|
+
"failed",
|
|
372
|
+
"spawn-error",
|
|
373
|
+
"cancelled",
|
|
374
|
+
] as const) {
|
|
375
|
+
test(`preserves originating schedule ${String(cronRunId)} on ${outcome}`, async () => {
|
|
376
|
+
const context = makeContext({ cronRunId });
|
|
377
|
+
await hostShellTool.execute(
|
|
378
|
+
{ command: "echo bg-local", background: true },
|
|
379
|
+
context,
|
|
380
|
+
);
|
|
381
|
+
context.cronRunId = "cron-run-later";
|
|
382
|
+
const ownedCommand = (
|
|
383
|
+
mockRegisterBackgroundTool.mock.calls as unknown[][]
|
|
384
|
+
)[0]![0] as BackgroundTool;
|
|
385
|
+
expect(ownedCommand.cronRunId).toBe(cronRunId ?? undefined);
|
|
386
|
+
if (outcome === "cancelled") {
|
|
387
|
+
const registered = (
|
|
388
|
+
mockRegisterBackgroundTool.mock.calls as unknown[][]
|
|
389
|
+
)[0]![0] as BackgroundTool;
|
|
390
|
+
registered.cancel();
|
|
391
|
+
}
|
|
392
|
+
if (outcome === "spawn-error") {
|
|
393
|
+
latestChild!.emit("error", new Error("spawn ENOENT"));
|
|
394
|
+
latestChild!.emit("close", null);
|
|
395
|
+
} else {
|
|
396
|
+
latestChild!.emit("close", outcome === "failed" ? 1 : 0);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
expect(mockWakeAgentForOpportunity).toHaveBeenCalledTimes(1);
|
|
400
|
+
const wake = (
|
|
401
|
+
mockWakeAgentForOpportunity.mock.calls as unknown[][]
|
|
402
|
+
)[0]![0] as WakeOptions;
|
|
403
|
+
expect(wake.cronRunId).toBe(cronRunId ?? undefined);
|
|
404
|
+
expect(wake.backgroundToolCompletion?.status).toBe(
|
|
405
|
+
outcome === "spawn-error" ? "failed" : outcome,
|
|
406
|
+
);
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
314
411
|
test("returns immediately with backgrounded response", async () => {
|
|
315
412
|
const ctx = makeContext();
|
|
316
413
|
|