@stigmer/runner 3.5.3 → 3.7.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/dist/.build-fingerprint +1 -1
- package/dist/activities/call-agent.js +85 -10
- package/dist/activities/call-agent.js.map +1 -1
- package/dist/activities/discover-mcp-server.js +9 -1
- package/dist/activities/discover-mcp-server.js.map +1 -1
- package/dist/activities/execute-cursor/index.d.ts +5 -0
- package/dist/activities/execute-cursor/index.js +88 -14
- package/dist/activities/execute-cursor/index.js.map +1 -1
- package/dist/activities/execute-cursor/model-pricing.d.ts +9 -0
- package/dist/activities/execute-cursor/model-pricing.js +19 -0
- package/dist/activities/execute-cursor/model-pricing.js.map +1 -1
- package/dist/activities/execute-cursor/prompt-builder.d.ts +7 -0
- package/dist/activities/execute-cursor/prompt-builder.js +9 -0
- package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
- package/dist/activities/execute-cursor/service-tier.d.ts +68 -0
- package/dist/activities/execute-cursor/service-tier.js +187 -0
- package/dist/activities/execute-cursor/service-tier.js.map +1 -0
- package/dist/activities/execute-cursor/session-lifecycle.d.ts +16 -1
- package/dist/activities/execute-cursor/session-lifecycle.js +12 -4
- package/dist/activities/execute-cursor/session-lifecycle.js.map +1 -1
- package/dist/activities/execute-cursor/usage-accumulator.d.ts +21 -1
- package/dist/activities/execute-cursor/usage-accumulator.js +23 -3
- package/dist/activities/execute-cursor/usage-accumulator.js.map +1 -1
- package/dist/activities/execute-deep-agent/prompt-builder.d.ts +6 -0
- package/dist/activities/execute-deep-agent/prompt-builder.js +3 -0
- package/dist/activities/execute-deep-agent/prompt-builder.js.map +1 -1
- package/dist/activities/execute-deep-agent/setup.js +45 -9
- package/dist/activities/execute-deep-agent/setup.js.map +1 -1
- package/dist/client/stigmer-client.d.ts +32 -1
- package/dist/client/stigmer-client.js +42 -2
- package/dist/client/stigmer-client.js.map +1 -1
- package/dist/main.js +18 -0
- package/dist/main.js.map +1 -1
- package/dist/runner.js +48 -0
- package/dist/runner.js.map +1 -1
- package/dist/sandbox-token-renewal.d.ts +65 -0
- package/dist/sandbox-token-renewal.js +169 -0
- package/dist/sandbox-token-renewal.js.map +1 -0
- package/dist/shared/artifact-storage.d.ts +17 -3
- package/dist/shared/artifact-storage.js +22 -4
- package/dist/shared/artifact-storage.js.map +1 -1
- package/dist/shared/caller-identity.d.ts +89 -0
- package/dist/shared/caller-identity.js +124 -0
- package/dist/shared/caller-identity.js.map +1 -0
- package/dist/shared/channel-attachment.d.ts +85 -0
- package/dist/shared/channel-attachment.js +203 -0
- package/dist/shared/channel-attachment.js.map +1 -0
- package/dist/shared/datastore-attachment.d.ts +2 -25
- package/dist/shared/datastore-attachment.js +1 -28
- package/dist/shared/datastore-attachment.js.map +1 -1
- package/dist/shared/synthesized-attachment.d.ts +51 -0
- package/dist/shared/synthesized-attachment.js +45 -0
- package/dist/shared/synthesized-attachment.js.map +1 -0
- package/dist/workflow-engine/loader.js +99 -2
- package/dist/workflow-engine/loader.js.map +1 -1
- package/dist/workflow-engine/tasks/call-agent.d.ts +0 -2
- package/dist/workflow-engine/tasks/call-agent.js +0 -2
- package/dist/workflow-engine/tasks/call-agent.js.map +1 -1
- package/dist/workflow-engine/types.d.ts +39 -7
- package/dist/workflow-engine/types.js.map +1 -1
- package/dist/workflows/call-agent-orchestrator.d.ts +3 -2
- package/dist/workflows/call-agent-orchestrator.js +8 -2
- package/dist/workflows/call-agent-orchestrator.js.map +1 -1
- package/package.json +2 -2
- package/src/__test-utils__/mock-client.ts +4 -0
- package/src/__tests__/sandbox-token-renewal.test.ts +174 -0
- package/src/activities/__tests__/call-agent-contracts.test.ts +4 -4
- package/src/activities/__tests__/call-agent.test.ts +219 -4
- package/src/activities/__tests__/discover-mcp-server.test.ts +49 -0
- package/src/activities/call-agent.ts +94 -10
- package/src/activities/discover-mcp-server.ts +13 -1
- package/src/activities/execute-cursor/__tests__/model-pricing.test.ts +20 -0
- package/src/activities/execute-cursor/__tests__/service-tier.test.ts +170 -0
- package/src/activities/execute-cursor/__tests__/usage-accumulator.test.ts +87 -1
- package/src/activities/execute-cursor/index.ts +121 -20
- package/src/activities/execute-cursor/model-pricing.ts +23 -0
- package/src/activities/execute-cursor/prompt-builder.ts +19 -0
- package/src/activities/execute-cursor/service-tier.ts +244 -0
- package/src/activities/execute-cursor/session-lifecycle.ts +33 -5
- package/src/activities/execute-cursor/usage-accumulator.ts +35 -3
- package/src/activities/execute-deep-agent/prompt-builder.ts +10 -0
- package/src/activities/execute-deep-agent/setup.ts +66 -10
- package/src/client/stigmer-client.ts +57 -4
- package/src/main.ts +20 -0
- package/src/runner.ts +62 -0
- package/src/sandbox-token-renewal.ts +212 -0
- package/src/shared/__tests__/caller-identity.test.ts +159 -0
- package/src/shared/__tests__/channel-attachment.test.ts +276 -0
- package/src/shared/__tests__/datastore-attachment.test.ts +4 -4
- package/src/shared/artifact-storage.ts +32 -7
- package/src/shared/caller-identity.ts +161 -0
- package/src/shared/channel-attachment.ts +237 -0
- package/src/shared/datastore-attachment.ts +2 -54
- package/src/shared/synthesized-attachment.ts +77 -0
- package/src/workflow-engine/__tests__/golden-execution.test.ts +8 -8
- package/src/workflow-engine/__tests__/loader.test.ts +192 -7
- package/src/workflow-engine/__tests__/tasks/call-agent.test.ts +9 -9
- package/src/workflow-engine/loader.ts +113 -2
- package/src/workflow-engine/tasks/call-agent.ts +0 -2
- package/src/workflow-engine/types.ts +40 -7
- package/src/workflows/call-agent-orchestrator.ts +8 -2
|
@@ -89,11 +89,23 @@ export class LocalArtifactStorage implements ArtifactStorage {
|
|
|
89
89
|
|
|
90
90
|
export class ProxyArtifactStorage implements ArtifactStorage {
|
|
91
91
|
private readonly baseUrl: string;
|
|
92
|
-
private readonly
|
|
92
|
+
private readonly authTokenSource: ProxyAuthTokenSource;
|
|
93
93
|
|
|
94
|
-
constructor(proxyEndpoint: string, authToken:
|
|
94
|
+
constructor(proxyEndpoint: string, authToken: ProxyAuthTokenSource) {
|
|
95
95
|
this.baseUrl = `${proxyEndpoint.replace(/\/+$/, "")}/v1/proxy/artifacts`;
|
|
96
|
-
this.
|
|
96
|
+
this.authTokenSource = authToken;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Resolve the credential per call rather than pinning the boot token: a
|
|
101
|
+
* cloud sandbox's control-plane token rotates in place (see
|
|
102
|
+
* sandbox-token-renewal.ts), and this storage lives for the pod's whole
|
|
103
|
+
* life — a captured string would silently 401 after the first rotation.
|
|
104
|
+
*/
|
|
105
|
+
private get authToken(): string {
|
|
106
|
+
return typeof this.authTokenSource === "string"
|
|
107
|
+
? this.authTokenSource
|
|
108
|
+
: (this.authTokenSource.current ?? "");
|
|
97
109
|
}
|
|
98
110
|
|
|
99
111
|
async upload(key: string, content: Buffer, contentType?: string): Promise<string> {
|
|
@@ -214,12 +226,18 @@ export class ProxyArtifactStorage implements ArtifactStorage {
|
|
|
214
226
|
|
|
215
227
|
// ── Factory ──────────────────────────────────────────────────────────
|
|
216
228
|
|
|
229
|
+
/**
|
|
230
|
+
* The proxy credential, either fixed (a caller-supplied string) or live (a
|
|
231
|
+
* shared mutable ref, read per call — the sandbox token-renewal posture).
|
|
232
|
+
*/
|
|
233
|
+
export type ProxyAuthTokenSource = string | { readonly current: string | null };
|
|
234
|
+
|
|
217
235
|
export interface ArtifactStorageConfig {
|
|
218
236
|
readonly type: ArtifactStorageType;
|
|
219
237
|
readonly localPath: string;
|
|
220
238
|
readonly localServeUrl: string;
|
|
221
239
|
readonly proxyEndpoint: string | null;
|
|
222
|
-
readonly proxyAuthToken:
|
|
240
|
+
readonly proxyAuthToken: ProxyAuthTokenSource | null;
|
|
223
241
|
}
|
|
224
242
|
|
|
225
243
|
export function loadArtifactStorageConfig(config: Config): ArtifactStorageConfig {
|
|
@@ -239,7 +257,11 @@ export function loadArtifactStorageConfig(config: Config): ArtifactStorageConfig
|
|
|
239
257
|
localPath: process.env.LOCAL_ARTIFACT_PATH ?? "/var/stigmer/artifacts",
|
|
240
258
|
localServeUrl: process.env.LOCAL_ARTIFACT_SERVE_URL ?? "http://localhost:7235",
|
|
241
259
|
proxyEndpoint: type === "proxy" ? (config.proxyEndpoint ?? null) : null,
|
|
242
|
-
|
|
260
|
+
// Prefer the live ref: renewal rotates the token in place and uploads
|
|
261
|
+
// must present the current credential, not the boot one.
|
|
262
|
+
proxyAuthToken: type === "proxy"
|
|
263
|
+
? (config.stigmerTokenRef ?? config.stigmerToken ?? null)
|
|
264
|
+
: null,
|
|
243
265
|
};
|
|
244
266
|
}
|
|
245
267
|
|
|
@@ -248,10 +270,13 @@ export function createArtifactStorage(cfg: ArtifactStorageConfig): ArtifactStora
|
|
|
248
270
|
if (!cfg.proxyEndpoint) {
|
|
249
271
|
throw new Error("Proxy artifact storage requires STIGMER_PROXY_ENDPOINT");
|
|
250
272
|
}
|
|
251
|
-
|
|
273
|
+
const tokenAtBoot = typeof cfg.proxyAuthToken === "string"
|
|
274
|
+
? cfg.proxyAuthToken
|
|
275
|
+
: cfg.proxyAuthToken?.current;
|
|
276
|
+
if (!tokenAtBoot) {
|
|
252
277
|
throw new Error("Proxy artifact storage requires STIGMER_TOKEN");
|
|
253
278
|
}
|
|
254
|
-
return new ProxyArtifactStorage(cfg.proxyEndpoint, cfg.proxyAuthToken);
|
|
279
|
+
return new ProxyArtifactStorage(cfg.proxyEndpoint, cfg.proxyAuthToken!);
|
|
255
280
|
}
|
|
256
281
|
|
|
257
282
|
return new LocalArtifactStorage(cfg.localPath, cfg.localServeUrl);
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The caller identity for MCP server configs — reserved platform env keys
|
|
3
|
+
* that carry the platform-verified "who is calling" into user-defined MCP
|
|
4
|
+
* server headers/args, without ever passing through the model.
|
|
5
|
+
*
|
|
6
|
+
* The identity is a (kind, value) pair with fixed precedence:
|
|
7
|
+
* 1. The channel sender (Meta/Slack-verified, stamped into
|
|
8
|
+
* `SessionSpec.metadata` by the cloud broker — sender-identity.ts is
|
|
9
|
+
* the reader).
|
|
10
|
+
* 2. The session creator (`stigmer_user`) from the Session resource's
|
|
11
|
+
* audit actor — console/CLI sessions have no channel sender, but the
|
|
12
|
+
* platform knows exactly who created the session.
|
|
13
|
+
* 3. The anonymous sentinel — discovery (no session exists) and sessions
|
|
14
|
+
* with no readable creator. Consumers must treat anonymous as a
|
|
15
|
+
* first-class caller: answer tools/list, refuse tool calls.
|
|
16
|
+
*
|
|
17
|
+
* Injection is opt-in by construction: the values enter the env map used
|
|
18
|
+
* for MCP placeholder resolution, and `filterEnvToDeclaredKeys` already
|
|
19
|
+
* restricts every server to the keys it declared in `spec.env`. A server
|
|
20
|
+
* that never declares the reserved keys never receives identity.
|
|
21
|
+
* Declarations MUST be `optional: true` — execution creation validates
|
|
22
|
+
* declared-env completeness in both editions, and these keys have no value
|
|
23
|
+
* until the runner injects them.
|
|
24
|
+
*
|
|
25
|
+
* Trust model: the resulting header is RUNNER-asserted, not signed. It
|
|
26
|
+
* closes the prompt-injection hole (the model cannot influence the value),
|
|
27
|
+
* but the receiving server must pair it with a shared secret and be
|
|
28
|
+
* operated by someone who trusts the runner's network path.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { readSenderIdentity } from "./sender-identity.js";
|
|
32
|
+
|
|
33
|
+
/** Reserved env key: the identity's kind token. */
|
|
34
|
+
export const CALLER_IDENTITY_KIND_ENV_KEY = "STIGMER_CALLER_IDENTITY_KIND";
|
|
35
|
+
|
|
36
|
+
/** Reserved env key: the identity's value. */
|
|
37
|
+
export const CALLER_IDENTITY_VALUE_ENV_KEY = "STIGMER_CALLER_IDENTITY_VALUE";
|
|
38
|
+
|
|
39
|
+
/** Reserved env key: the session the identity was resolved for. */
|
|
40
|
+
export const SESSION_ID_ENV_KEY = "STIGMER_SESSION_ID";
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Kind token for a platform user (console/CLI session creator). Channel
|
|
44
|
+
* kinds (`whatsapp_phone`, `slack_user_id`, ...) pass through VERBATIM
|
|
45
|
+
* from the cloud broker's metadata — this module never rewrites them.
|
|
46
|
+
*/
|
|
47
|
+
export const STIGMER_USER_KIND = "stigmer_user";
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Kind token for the anonymous caller. Deliberately a real token rather
|
|
51
|
+
* than an absent key: every declared placeholder must resolve in every
|
|
52
|
+
* resolution context, or discovery fails with PlaceholderResolutionError
|
|
53
|
+
* before the server's tools are ever classified.
|
|
54
|
+
*/
|
|
55
|
+
export const ANONYMOUS_KIND = "anonymous";
|
|
56
|
+
|
|
57
|
+
/** The resolved caller identity. */
|
|
58
|
+
export interface CallerIdentity {
|
|
59
|
+
kind: string;
|
|
60
|
+
value: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The audit actor shape read from `status.audit.spec_audit.created_by`. */
|
|
64
|
+
export interface SessionCreatorActor {
|
|
65
|
+
id?: string;
|
|
66
|
+
email?: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The identity injected when no session context exists (discovery). */
|
|
70
|
+
export function anonymousCallerIdentity(): CallerIdentity {
|
|
71
|
+
return { kind: ANONYMOUS_KIND, value: "" };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Resolve the caller identity for a session: channel sender first, then
|
|
76
|
+
* the session creator, then anonymous.
|
|
77
|
+
*
|
|
78
|
+
* The creator value prefers email over id: bindings are maintained by
|
|
79
|
+
* humans, and the audit actor's `id` field is historically mixed
|
|
80
|
+
* (identity-account id vs email — see the proto's own @internal note).
|
|
81
|
+
* Binding matchers should compare emails case-insensitively.
|
|
82
|
+
*/
|
|
83
|
+
export function resolveCallerIdentity(
|
|
84
|
+
sessionMetadata: Record<string, string> | undefined,
|
|
85
|
+
creator?: SessionCreatorActor,
|
|
86
|
+
): CallerIdentity {
|
|
87
|
+
const sender = readSenderIdentity(sessionMetadata);
|
|
88
|
+
if (sender) {
|
|
89
|
+
return { kind: sender.kind, value: sender.value };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const email = creator?.email?.trim();
|
|
93
|
+
const id = creator?.id?.trim();
|
|
94
|
+
const value = email || id;
|
|
95
|
+
if (value) {
|
|
96
|
+
return { kind: STIGMER_USER_KIND, value };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return anonymousCallerIdentity();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Return a NEW env map with the reserved caller-identity keys set —
|
|
104
|
+
* platform values are authoritative over same-named user entries (the
|
|
105
|
+
* injectPlatformEnv precedent: a user env var must never be able to
|
|
106
|
+
* impersonate a caller).
|
|
107
|
+
*
|
|
108
|
+
* Call this on the env map handed to MCP resolution ONLY — never on the
|
|
109
|
+
* map that reaches agent subprocess environments. Per-server opt-in is
|
|
110
|
+
* enforced downstream by filterEnvToDeclaredKeys.
|
|
111
|
+
*/
|
|
112
|
+
export function injectCallerIdentityEnv(
|
|
113
|
+
envVars: Record<string, string>,
|
|
114
|
+
identity: CallerIdentity,
|
|
115
|
+
sessionId: string,
|
|
116
|
+
): Record<string, string> {
|
|
117
|
+
const reserved: Record<string, string> = {
|
|
118
|
+
[CALLER_IDENTITY_KIND_ENV_KEY]: identity.kind,
|
|
119
|
+
[CALLER_IDENTITY_VALUE_ENV_KEY]: identity.value,
|
|
120
|
+
[SESSION_ID_ENV_KEY]: sessionId,
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
for (const [key, value] of Object.entries(reserved)) {
|
|
124
|
+
if (key in envVars && envVars[key] !== value) {
|
|
125
|
+
console.info(
|
|
126
|
+
`Platform env var '${key}' overrides value from ExecutionContext ` +
|
|
127
|
+
`(caller-identity vars are authoritative)`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return { ...envVars, ...reserved };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Discovery-context injection: the connect workflow resolves the same
|
|
137
|
+
* header templates with no session, so every declared reserved key gets
|
|
138
|
+
* the anonymous sentinel — otherwise a caller-identity-templating server
|
|
139
|
+
* can never be discovered. Gated on the server's declared keys, matching
|
|
140
|
+
* injectPlatformEnv's contract in the discovery activity.
|
|
141
|
+
*/
|
|
142
|
+
export function injectAnonymousCallerIdentityForDiscovery(
|
|
143
|
+
declaredEnvKeys: ReadonlySet<string>,
|
|
144
|
+
envVars: Record<string, string>,
|
|
145
|
+
): Record<string, string> {
|
|
146
|
+
const anonymous = anonymousCallerIdentity();
|
|
147
|
+
const sentinels: Record<string, string> = {
|
|
148
|
+
[CALLER_IDENTITY_KIND_ENV_KEY]: anonymous.kind,
|
|
149
|
+
[CALLER_IDENTITY_VALUE_ENV_KEY]: anonymous.value,
|
|
150
|
+
[SESSION_ID_ENV_KEY]: "",
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
let result: Record<string, string> | undefined;
|
|
154
|
+
for (const [key, value] of Object.entries(sentinels)) {
|
|
155
|
+
if (!declaredEnvKeys.has(key)) continue;
|
|
156
|
+
if (!result) result = { ...envVars };
|
|
157
|
+
result[key] = value;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return result ?? envVars;
|
|
161
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The runner-synthesized channel messaging attachment (proactive-messaging
|
|
3
|
+
* DD-006 D7/D8) — the datastore records attachment's structural twin.
|
|
4
|
+
*
|
|
5
|
+
* When the control plane says an agent serves at least one
|
|
6
|
+
* proactive-messaging channel (the `listMessagingChannels` discovery
|
|
7
|
+
* read, DD-006 D2 — the SAME candidate computation the send
|
|
8
|
+
* authorization runs, so attachment and authority cannot disagree), the
|
|
9
|
+
* runner synthesizes ONE MCP attachment serving `send_channel_message`,
|
|
10
|
+
* and injects the `<available_channel_templates>` prompt section so the
|
|
11
|
+
* model composes template sends in context without spending a tool
|
|
12
|
+
* round (DD-003 D5).
|
|
13
|
+
*
|
|
14
|
+
* Two connection shapes, one roster (the records pattern):
|
|
15
|
+
* - Bridge endpoint configured (cloud): Streamable HTTP against the
|
|
16
|
+
* bridge's /channels route with the execution's own session-scoped
|
|
17
|
+
* credential as the Bearer token.
|
|
18
|
+
* - No bridge endpoint (OSS/local): a spawned `stigmer mcp-server`
|
|
19
|
+
* stdio child with STIGMER_MCP_ROSTER=channels. In practice OSS
|
|
20
|
+
* answers the discovery read with an empty list (DD-006 D3), so
|
|
21
|
+
* this shape only serves local deployments that grow a messaging
|
|
22
|
+
* runtime later — it exists for symmetry with the deployment
|
|
23
|
+
* topology, not for a live OSS path today.
|
|
24
|
+
*
|
|
25
|
+
* Approval-free by construction, and FORCED, not convenient (DD-002
|
|
26
|
+
* D6): both calling surfaces run APPROVAL_MODE_UNATTENDED, where a
|
|
27
|
+
* gated tool resolves as skip-and-adapt — a gated send tool would mean
|
|
28
|
+
* reminders never send. Empty approval maps + no McpServerUsage keep
|
|
29
|
+
* the connect backfill structurally unable to gate it (see
|
|
30
|
+
* synthesized-attachment.ts). Callers inject AFTER resolve + backfill.
|
|
31
|
+
*
|
|
32
|
+
* Failure posture (DD-006 D4): every discovery failure — OSS's empty
|
|
33
|
+
* answer, a registry outage, a control plane predating the RPC
|
|
34
|
+
* (UNIMPLEMENTED), a reach refusal — degrades to honest absence: no
|
|
35
|
+
* tool, no section, execution unharmed.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { Code, ConnectError } from "@connectrpc/connect";
|
|
39
|
+
import type {
|
|
40
|
+
ChannelTemplate,
|
|
41
|
+
MessagingChannel,
|
|
42
|
+
} from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/message_io_pb";
|
|
43
|
+
import type { StigmerClient } from "../client/stigmer-client.js";
|
|
44
|
+
import type { ResolvedMcpServer } from "./mcp-resolver.js";
|
|
45
|
+
import { grpcTarget, type SynthesizedAttachmentOptions } from "./synthesized-attachment.js";
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The synthesized attachment's slug. Reserved: a user McpServer with
|
|
49
|
+
* this slug is shadowed by the synthesized attachment, with a warning.
|
|
50
|
+
* Pinned cross-repo by the mcp-server integration test (the
|
|
51
|
+
* TOOL_CALL_LIMIT precedent).
|
|
52
|
+
*/
|
|
53
|
+
export const CHANNEL_ATTACHMENT_SLUG = "stigmer-channels";
|
|
54
|
+
|
|
55
|
+
/** The bridge route serving the channels-only roster (mcp-server DD-006 D8). */
|
|
56
|
+
export const CHANNELS_ROUTE = "/channels";
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The most templates the prompt section carries (DD-006 D6): Meta
|
|
60
|
+
* allows hundreds per WABA, and an unbounded section would tax every
|
|
61
|
+
* run's context. Deterministic (name, language) order plus a withheld
|
|
62
|
+
* count keep the agent's behavior independent of registry ordering.
|
|
63
|
+
*/
|
|
64
|
+
export const TEMPLATE_SECTION_CAP = 30;
|
|
65
|
+
|
|
66
|
+
/** One channel plus its sendable approved templates, ready to format. */
|
|
67
|
+
export interface ChannelMessagingInfo {
|
|
68
|
+
channel: MessagingChannel;
|
|
69
|
+
templates: ChannelTemplate[];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The discovery read plus the per-channel template fetch, with the
|
|
74
|
+
* DD-006 D4 failure posture applied: this function NEVER throws — any
|
|
75
|
+
* failure returns an empty list (no tool, no section), because a
|
|
76
|
+
* messaging hiccup must not fail an execution that may not even want
|
|
77
|
+
* to send anything.
|
|
78
|
+
*/
|
|
79
|
+
export async function discoverChannelMessaging(
|
|
80
|
+
client: StigmerClient,
|
|
81
|
+
scopedCredential: string | undefined,
|
|
82
|
+
): Promise<ChannelMessagingInfo[]> {
|
|
83
|
+
let channels: MessagingChannel[];
|
|
84
|
+
try {
|
|
85
|
+
channels = await client.listMessagingChannels(scopedCredential);
|
|
86
|
+
} catch (err) {
|
|
87
|
+
logDiscoveryFailure("listMessagingChannels", err);
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
90
|
+
if (channels.length === 0) {
|
|
91
|
+
return [];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Template reads degrade PER CHANNEL: a registry outage on one
|
|
95
|
+
// channel must not strip the section for another, and never the tool
|
|
96
|
+
// (a channel with unreadable templates can still send text inside a
|
|
97
|
+
// 24-hour window).
|
|
98
|
+
return Promise.all(channels.map(async (channel) => {
|
|
99
|
+
try {
|
|
100
|
+
return {
|
|
101
|
+
channel,
|
|
102
|
+
templates: await client.listChannelTemplates(channel.channel, scopedCredential),
|
|
103
|
+
};
|
|
104
|
+
} catch (err) {
|
|
105
|
+
logDiscoveryFailure(`listTemplates(${channel.channel})`, err);
|
|
106
|
+
return { channel, templates: [] };
|
|
107
|
+
}
|
|
108
|
+
}));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Synthesize the channel messaging attachment. Returns undefined when
|
|
113
|
+
* the agent serves no proactive channel — the attachment exists exactly
|
|
114
|
+
* when the discovery read says so.
|
|
115
|
+
*/
|
|
116
|
+
export function synthesizeChannelAttachment(
|
|
117
|
+
channels: ChannelMessagingInfo[],
|
|
118
|
+
options: SynthesizedAttachmentOptions,
|
|
119
|
+
): ResolvedMcpServer | undefined {
|
|
120
|
+
if (channels.length === 0) {
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Approval-free by construction + backfill-proof: see file header.
|
|
125
|
+
const base = {
|
|
126
|
+
slug: CHANNEL_ATTACHMENT_SLUG,
|
|
127
|
+
toolApprovals: [],
|
|
128
|
+
pinnedToolApprovals: [],
|
|
129
|
+
discoveredCapabilitiesEmpty: false,
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
if (options.bridgeEndpoint !== null && options.bridgeEndpoint !== "") {
|
|
133
|
+
return {
|
|
134
|
+
...base,
|
|
135
|
+
connectionType: "http",
|
|
136
|
+
url: options.bridgeEndpoint.replace(/\/+$/, "") + CHANNELS_ROUTE,
|
|
137
|
+
headers: options.credential !== null && options.credential !== ""
|
|
138
|
+
? { Authorization: `Bearer ${options.credential}` }
|
|
139
|
+
: undefined,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
...base,
|
|
145
|
+
connectionType: "stdio",
|
|
146
|
+
command: "stigmer",
|
|
147
|
+
args: ["mcp-server"],
|
|
148
|
+
env: {
|
|
149
|
+
STIGMER_MCP_ROSTER: "channels",
|
|
150
|
+
STIGMER_SERVER_ADDRESS: grpcTarget(options.backendEndpoint),
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The `<available_channel_templates>` prompt section (DD-003 D5):
|
|
157
|
+
* approved AND sendable templates with their full body text, so the
|
|
158
|
+
* model fills positional placeholders beside the values it composes.
|
|
159
|
+
* Unsendable entries are filtered, not annotated (DD-006 D6 — the
|
|
160
|
+
* console panel is the diagnosis surface, the prompt is a composition
|
|
161
|
+
* aid). Returns "" when nothing survives the filter — the tool alone
|
|
162
|
+
* still serves text sends inside a 24-hour window.
|
|
163
|
+
*/
|
|
164
|
+
export function formatChannelTemplatesSection(channels: ChannelMessagingInfo[]): string {
|
|
165
|
+
let withheld = 0;
|
|
166
|
+
let budget = TEMPLATE_SECTION_CAP;
|
|
167
|
+
|
|
168
|
+
const channelBlocks: string[] = [];
|
|
169
|
+
for (const { channel, templates } of channels) {
|
|
170
|
+
// Sendable-only (unsupportedReason empty), deterministic order —
|
|
171
|
+
// agent behavior must never depend on registry ordering (DD-006 D6).
|
|
172
|
+
const sendable = templates
|
|
173
|
+
.filter((t) => t.unsupportedReason === "")
|
|
174
|
+
.sort((a, b) => a.name.localeCompare(b.name) || a.language.localeCompare(b.language));
|
|
175
|
+
|
|
176
|
+
const kept = sendable.slice(0, Math.max(budget, 0));
|
|
177
|
+
withheld += sendable.length - kept.length;
|
|
178
|
+
budget -= kept.length;
|
|
179
|
+
if (kept.length === 0) {
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const lines = kept.map((t) => {
|
|
184
|
+
const parameters = t.parameterNames.length > 0
|
|
185
|
+
? `, parameters: ${t.parameterNames.join(", ")}`
|
|
186
|
+
: "";
|
|
187
|
+
const header = t.headerFormat === "IMAGE"
|
|
188
|
+
? " (requires header_image_link: a public HTTPS image URL)"
|
|
189
|
+
: "";
|
|
190
|
+
return [
|
|
191
|
+
` - ${t.name} (${t.language}) [${t.category}]${parameters}${header}`,
|
|
192
|
+
` "${t.bodyText}"`,
|
|
193
|
+
].join("\n");
|
|
194
|
+
});
|
|
195
|
+
channelBlocks.push([`channel: ${channel.channel} (${channel.provider})`, ...lines].join("\n"));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (channelBlocks.length === 0) {
|
|
199
|
+
return "";
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const footer = withheld > 0
|
|
203
|
+
? [`(${withheld} more approved template${withheld === 1 ? "" : "s"} not shown)`]
|
|
204
|
+
: [];
|
|
205
|
+
return [
|
|
206
|
+
"<available_channel_templates>",
|
|
207
|
+
"You can send business-initiated messages on the channels below with the",
|
|
208
|
+
"send_channel_message tool. Outside a 24-hour customer-service window the",
|
|
209
|
+
"provider only accepts a pre-approved template, so prefer a template. Fill",
|
|
210
|
+
"every placeholder from the conversation; never invent a value.",
|
|
211
|
+
"",
|
|
212
|
+
...channelBlocks,
|
|
213
|
+
...footer,
|
|
214
|
+
"</available_channel_templates>",
|
|
215
|
+
].join("\n");
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Expected absences log quietly; anything else warns so an operator can
|
|
220
|
+
* diagnose a mis-provisioned credential without failing the execution.
|
|
221
|
+
* UNIMPLEMENTED is a control plane predating the discovery RPC — the
|
|
222
|
+
* DD-006 D4 deploy-order self-healing case.
|
|
223
|
+
*/
|
|
224
|
+
function logDiscoveryFailure(what: string, err: unknown): void {
|
|
225
|
+
const ce = ConnectError.from(err);
|
|
226
|
+
const expected =
|
|
227
|
+
ce.code === Code.Unimplemented ||
|
|
228
|
+
ce.code === Code.FailedPrecondition ||
|
|
229
|
+
ce.code === Code.Unavailable;
|
|
230
|
+
if (expected) {
|
|
231
|
+
console.debug(`[channel-attachment] ${what} degraded to honest absence: ${ce.message}`);
|
|
232
|
+
} else {
|
|
233
|
+
console.warn(
|
|
234
|
+
`[channel-attachment] ${what} failed unexpectedly (no tool, no section): ${ce.message}`,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
|
|
34
34
|
import type { DatastoreUsage } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/spec_pb";
|
|
35
35
|
import type { ResolvedMcpServer } from "./mcp-resolver.js";
|
|
36
|
+
import { grpcTarget, type SynthesizedAttachmentOptions } from "./synthesized-attachment.js";
|
|
36
37
|
|
|
37
38
|
/**
|
|
38
39
|
* The synthesized attachment's slug. Reserved: a user McpServer with
|
|
@@ -44,25 +45,6 @@ export const DATASTORE_ATTACHMENT_SLUG = "stigmer-records";
|
|
|
44
45
|
/** The bridge route serving the records-only roster (mcp-server T05 R1). */
|
|
45
46
|
export const RECORDS_ROUTE = "/records";
|
|
46
47
|
|
|
47
|
-
export interface DatastoreAttachmentOptions {
|
|
48
|
-
/**
|
|
49
|
-
* The bridge's HTTP endpoint (STIGMER_MCP_BRIDGE_ENDPOINT, e.g.
|
|
50
|
-
* https://mcp.stigmer.ai). Null selects the OSS stdio shape.
|
|
51
|
-
*/
|
|
52
|
-
bridgeEndpoint: string | null;
|
|
53
|
-
/**
|
|
54
|
-
* The execution's session-scoped credential (the sandbox token a
|
|
55
|
-
* cloud runner holds, or the desktop runner's exchanged scoped
|
|
56
|
-
* token). Null attaches no Authorization header (OSS/local).
|
|
57
|
-
*/
|
|
58
|
-
credential: string | null;
|
|
59
|
-
/**
|
|
60
|
-
* The stigmer backend endpoint the stdio child dials
|
|
61
|
-
* (config.stigmerBackendEndpoint). Only used for the OSS shape.
|
|
62
|
-
*/
|
|
63
|
-
backendEndpoint: string;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
48
|
/**
|
|
67
49
|
* Synthesize the records attachment for an agent's datastore usages.
|
|
68
50
|
* Returns undefined when the agent uses no datastores — the attachment
|
|
@@ -70,7 +52,7 @@ export interface DatastoreAttachmentOptions {
|
|
|
70
52
|
*/
|
|
71
53
|
export function synthesizeDatastoreAttachment(
|
|
72
54
|
datastoreUsages: DatastoreUsage[],
|
|
73
|
-
options:
|
|
55
|
+
options: SynthesizedAttachmentOptions,
|
|
74
56
|
): ResolvedMcpServer | undefined {
|
|
75
57
|
if (datastoreUsages.length === 0) {
|
|
76
58
|
return undefined;
|
|
@@ -107,28 +89,6 @@ export function synthesizeDatastoreAttachment(
|
|
|
107
89
|
};
|
|
108
90
|
}
|
|
109
91
|
|
|
110
|
-
/**
|
|
111
|
-
* Inject the synthesized attachment into a resolved server list —
|
|
112
|
-
* AFTER resolve + backfill (see file header). A user server shadowing
|
|
113
|
-
* the reserved slug is replaced, loudly.
|
|
114
|
-
*/
|
|
115
|
-
export function injectDatastoreAttachment(
|
|
116
|
-
resolvedServers: ResolvedMcpServer[],
|
|
117
|
-
attachment: ResolvedMcpServer,
|
|
118
|
-
): ResolvedMcpServer[] {
|
|
119
|
-
const shadowed = resolvedServers.some((s) => s.slug === attachment.slug);
|
|
120
|
-
if (shadowed) {
|
|
121
|
-
console.warn(
|
|
122
|
-
`MCP server slug "${attachment.slug}" is reserved for the datastore ` +
|
|
123
|
-
"records attachment; the user-defined server is replaced.",
|
|
124
|
-
);
|
|
125
|
-
}
|
|
126
|
-
return [
|
|
127
|
-
...resolvedServers.filter((s) => s.slug !== attachment.slug),
|
|
128
|
-
attachment,
|
|
129
|
-
];
|
|
130
|
-
}
|
|
131
|
-
|
|
132
92
|
/**
|
|
133
93
|
* The `<available_datastores>` prompt section (DD-005 SD-5, the
|
|
134
94
|
* skills-section precedent): names the attached datastores and points
|
|
@@ -152,15 +112,3 @@ export function formatDatastoresSection(datastoreUsages: DatastoreUsage[]): stri
|
|
|
152
112
|
"</available_datastores>",
|
|
153
113
|
].join("\n");
|
|
154
114
|
}
|
|
155
|
-
|
|
156
|
-
/**
|
|
157
|
-
* The gRPC dial target (host:port) for a backend endpoint URL — the
|
|
158
|
-
* shape STIGMER_SERVER_ADDRESS wants (the bridge warns on schemes).
|
|
159
|
-
*/
|
|
160
|
-
function grpcTarget(endpoint: string): string {
|
|
161
|
-
try {
|
|
162
|
-
return new URL(endpoint).host;
|
|
163
|
-
} catch {
|
|
164
|
-
return endpoint;
|
|
165
|
-
}
|
|
166
|
-
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared mechanics of runner-synthesized MCP attachments — the pieces
|
|
3
|
+
* the datastore records attachment (T05) and the channel messaging
|
|
4
|
+
* attachment (proactive-messaging DD-006 D8) have in common, extracted
|
|
5
|
+
* when the second attachment arrived.
|
|
6
|
+
*
|
|
7
|
+
* A synthesized attachment is a first-party MCP server entry the runner
|
|
8
|
+
* builds itself (no McpServer resource, no Environment, no credential in
|
|
9
|
+
* any manifest) on a RESERVED slug. Approval-freedom is structural, not
|
|
10
|
+
* configured: empty toolApprovals + pinnedToolApprovals mean
|
|
11
|
+
* mergeApprovalPolicies emits no entries, and discoveredCapabilitiesEmpty
|
|
12
|
+
* false + no McpServerUsage keep the connect backfill's destructiveHint
|
|
13
|
+
* tightener structurally unable to touch it. Callers must still inject
|
|
14
|
+
* AFTER resolve + backfill; every harness call site does.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { ResolvedMcpServer } from "./mcp-resolver.js";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Connection options for a synthesized attachment — one shape for every
|
|
21
|
+
* attachment because the deployment topology, not the domain, decides
|
|
22
|
+
* the connection.
|
|
23
|
+
*/
|
|
24
|
+
export interface SynthesizedAttachmentOptions {
|
|
25
|
+
/**
|
|
26
|
+
* The bridge's HTTP endpoint (STIGMER_MCP_BRIDGE_ENDPOINT, e.g.
|
|
27
|
+
* https://mcp.stigmer.ai). Null selects the OSS/local stdio shape.
|
|
28
|
+
*/
|
|
29
|
+
bridgeEndpoint: string | null;
|
|
30
|
+
/**
|
|
31
|
+
* The execution's session-scoped credential (the sandbox token a
|
|
32
|
+
* cloud runner holds, or the desktop runner's exchanged scoped
|
|
33
|
+
* token). Null attaches no Authorization header (OSS/local).
|
|
34
|
+
*/
|
|
35
|
+
credential: string | null;
|
|
36
|
+
/**
|
|
37
|
+
* The stigmer backend endpoint the stdio child dials
|
|
38
|
+
* (config.stigmerBackendEndpoint). Only used for the OSS shape.
|
|
39
|
+
*/
|
|
40
|
+
backendEndpoint: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Inject a synthesized attachment into a resolved server list — AFTER
|
|
45
|
+
* resolve + backfill (see the module header). A user server shadowing
|
|
46
|
+
* the reserved slug is replaced, loudly; `label` names the attachment
|
|
47
|
+
* in that warning (e.g. "datastore records").
|
|
48
|
+
*/
|
|
49
|
+
export function injectSynthesizedAttachment(
|
|
50
|
+
resolvedServers: ResolvedMcpServer[],
|
|
51
|
+
attachment: ResolvedMcpServer,
|
|
52
|
+
label: string,
|
|
53
|
+
): ResolvedMcpServer[] {
|
|
54
|
+
const shadowed = resolvedServers.some((s) => s.slug === attachment.slug);
|
|
55
|
+
if (shadowed) {
|
|
56
|
+
console.warn(
|
|
57
|
+
`MCP server slug "${attachment.slug}" is reserved for the ${label} ` +
|
|
58
|
+
"attachment; the user-defined server is replaced.",
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
return [
|
|
62
|
+
...resolvedServers.filter((s) => s.slug !== attachment.slug),
|
|
63
|
+
attachment,
|
|
64
|
+
];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The gRPC dial target (host:port) for a backend endpoint URL — the
|
|
69
|
+
* shape STIGMER_SERVER_ADDRESS wants (the bridge warns on schemes).
|
|
70
|
+
*/
|
|
71
|
+
export function grpcTarget(endpoint: string): string {
|
|
72
|
+
try {
|
|
73
|
+
return new URL(endpoint).host;
|
|
74
|
+
} catch {
|
|
75
|
+
return endpoint;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -404,9 +404,9 @@ describe("Golden Execution — Tier 1d: Advanced Tasks", () => {
|
|
|
404
404
|
expect(config.message).not.toContain("${ $context");
|
|
405
405
|
expect(config.message).not.toContain("${ $input");
|
|
406
406
|
expect(config.env).toEqual({ GITHUB_TOKEN: "${.secrets.GITHUB_TOKEN}" });
|
|
407
|
-
expect(config.
|
|
408
|
-
expect(config.
|
|
409
|
-
expect(config.
|
|
407
|
+
expect(config.run_config?.model_name).toBe("claude-3-5-sonnet");
|
|
408
|
+
expect(config.run_config?.max_cost_usd).toBe(0.75);
|
|
409
|
+
expect(config.run_config?.service_tier).toBe("SERVICE_TIER_STANDARD");
|
|
410
410
|
expect(config.harness).toBe("HARNESS_NATIVE");
|
|
411
411
|
expect(config.output?.schema).toBeDefined();
|
|
412
412
|
expect(config.output?.schema.required).toContain("severity");
|
|
@@ -428,7 +428,6 @@ describe("Golden Execution — Tier 1d: Advanced Tasks", () => {
|
|
|
428
428
|
" with:",
|
|
429
429
|
' agent: "stigmer/code-reviewer"',
|
|
430
430
|
' message: "Review this"',
|
|
431
|
-
' org: "stigmer"',
|
|
432
431
|
].join("\n");
|
|
433
432
|
|
|
434
433
|
const model = loadWorkflowFromYaml(crossOrgYaml);
|
|
@@ -444,7 +443,6 @@ describe("Golden Execution — Tier 1d: Advanced Tasks", () => {
|
|
|
444
443
|
expect(mockCallAgent).toHaveBeenCalledOnce();
|
|
445
444
|
const [config] = mockCallAgent.mock.calls[0];
|
|
446
445
|
expect(config.agent).toBe("stigmer/code-reviewer");
|
|
447
|
-
expect(config.org).toBe("stigmer");
|
|
448
446
|
expect(config.message).toBe("Review this");
|
|
449
447
|
});
|
|
450
448
|
|
|
@@ -1048,9 +1046,11 @@ describe("Golden Execution — Structured Output Pipeline", () => {
|
|
|
1048
1046
|
expect(config.message).toContain("Date: 2026-05-26");
|
|
1049
1047
|
expect(config.message).not.toContain("${ $env");
|
|
1050
1048
|
|
|
1051
|
-
//
|
|
1052
|
-
|
|
1053
|
-
|
|
1049
|
+
// Run config and harness preserved. The tier arrives canonical from the
|
|
1050
|
+
// golden's "fast" shorthand — the loader mapping riding a full workflow
|
|
1051
|
+
// load (#357).
|
|
1052
|
+
expect(config.run_config?.model_name).toBe("claude-sonnet-4");
|
|
1053
|
+
expect(config.run_config?.service_tier).toBe("SERVICE_TIER_FAST");
|
|
1054
1054
|
expect(config.harness).toBe("HARNESS_CURSOR");
|
|
1055
1055
|
expect(metadata.taskName).toBe("analyze_player_data");
|
|
1056
1056
|
|