@stigmer/runner 3.6.0 → 3.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.
Files changed (100) hide show
  1. package/dist/.build-fingerprint +1 -1
  2. package/dist/activities/call-agent.js +85 -10
  3. package/dist/activities/call-agent.js.map +1 -1
  4. package/dist/activities/execute-cursor/index.d.ts +12 -0
  5. package/dist/activities/execute-cursor/index.js +80 -10
  6. package/dist/activities/execute-cursor/index.js.map +1 -1
  7. package/dist/activities/execute-cursor/model-pricing.d.ts +9 -0
  8. package/dist/activities/execute-cursor/model-pricing.js +19 -0
  9. package/dist/activities/execute-cursor/model-pricing.js.map +1 -1
  10. package/dist/activities/execute-cursor/prompt-builder.d.ts +11 -0
  11. package/dist/activities/execute-cursor/prompt-builder.js +11 -0
  12. package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
  13. package/dist/activities/execute-cursor/service-tier.d.ts +68 -0
  14. package/dist/activities/execute-cursor/service-tier.js +187 -0
  15. package/dist/activities/execute-cursor/service-tier.js.map +1 -0
  16. package/dist/activities/execute-cursor/session-lifecycle.d.ts +16 -1
  17. package/dist/activities/execute-cursor/session-lifecycle.js +12 -4
  18. package/dist/activities/execute-cursor/session-lifecycle.js.map +1 -1
  19. package/dist/activities/execute-cursor/usage-accumulator.d.ts +21 -1
  20. package/dist/activities/execute-cursor/usage-accumulator.js +23 -3
  21. package/dist/activities/execute-cursor/usage-accumulator.js.map +1 -1
  22. package/dist/activities/execute-deep-agent/mcp-gate.d.ts +28 -0
  23. package/dist/activities/execute-deep-agent/mcp-gate.js +22 -0
  24. package/dist/activities/execute-deep-agent/mcp-gate.js.map +1 -0
  25. package/dist/activities/execute-deep-agent/prompt-builder.d.ts +11 -0
  26. package/dist/activities/execute-deep-agent/prompt-builder.js +16 -0
  27. package/dist/activities/execute-deep-agent/prompt-builder.js.map +1 -1
  28. package/dist/activities/execute-deep-agent/setup.js +30 -4
  29. package/dist/activities/execute-deep-agent/setup.js.map +1 -1
  30. package/dist/client/stigmer-client.d.ts +6 -1
  31. package/dist/client/stigmer-client.js +5 -2
  32. package/dist/client/stigmer-client.js.map +1 -1
  33. package/dist/main.js +18 -0
  34. package/dist/main.js.map +1 -1
  35. package/dist/runner.js +48 -0
  36. package/dist/runner.js.map +1 -1
  37. package/dist/sandbox-token-renewal.d.ts +65 -0
  38. package/dist/sandbox-token-renewal.js +169 -0
  39. package/dist/sandbox-token-renewal.js.map +1 -0
  40. package/dist/shared/artifact-storage.d.ts +17 -3
  41. package/dist/shared/artifact-storage.js +22 -4
  42. package/dist/shared/artifact-storage.js.map +1 -1
  43. package/dist/shared/channel-attachment.d.ts +3 -1
  44. package/dist/shared/channel-attachment.js +3 -1
  45. package/dist/shared/channel-attachment.js.map +1 -1
  46. package/dist/shared/conversation-attachment.d.ts +81 -0
  47. package/dist/shared/conversation-attachment.js +102 -0
  48. package/dist/shared/conversation-attachment.js.map +1 -0
  49. package/dist/shared/conversation-catchup.d.ts +33 -0
  50. package/dist/shared/conversation-catchup.js +53 -0
  51. package/dist/shared/conversation-catchup.js.map +1 -0
  52. package/dist/workflow-engine/loader.js +99 -2
  53. package/dist/workflow-engine/loader.js.map +1 -1
  54. package/dist/workflow-engine/tasks/call-agent.d.ts +0 -2
  55. package/dist/workflow-engine/tasks/call-agent.js +0 -2
  56. package/dist/workflow-engine/tasks/call-agent.js.map +1 -1
  57. package/dist/workflow-engine/types.d.ts +39 -7
  58. package/dist/workflow-engine/types.js.map +1 -1
  59. package/dist/workflows/call-agent-orchestrator.d.ts +3 -2
  60. package/dist/workflows/call-agent-orchestrator.js +8 -2
  61. package/dist/workflows/call-agent-orchestrator.js.map +1 -1
  62. package/package.json +2 -2
  63. package/src/__tests__/sandbox-token-renewal.test.ts +174 -0
  64. package/src/activities/__tests__/call-agent-contracts.test.ts +4 -4
  65. package/src/activities/__tests__/call-agent.test.ts +219 -4
  66. package/src/activities/call-agent.ts +94 -10
  67. package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +79 -0
  68. package/src/activities/execute-cursor/__tests__/model-pricing.test.ts +20 -0
  69. package/src/activities/execute-cursor/__tests__/service-tier.test.ts +170 -0
  70. package/src/activities/execute-cursor/__tests__/usage-accumulator.test.ts +87 -1
  71. package/src/activities/execute-cursor/index.ts +111 -11
  72. package/src/activities/execute-cursor/model-pricing.ts +23 -0
  73. package/src/activities/execute-cursor/prompt-builder.ts +23 -0
  74. package/src/activities/execute-cursor/service-tier.ts +244 -0
  75. package/src/activities/execute-cursor/session-lifecycle.ts +33 -5
  76. package/src/activities/execute-cursor/usage-accumulator.ts +35 -3
  77. package/src/activities/execute-deep-agent/__tests__/mcp-gate.test.ts +42 -0
  78. package/src/activities/execute-deep-agent/__tests__/prompt-builder.test.ts +39 -1
  79. package/src/activities/execute-deep-agent/mcp-gate.ts +37 -0
  80. package/src/activities/execute-deep-agent/prompt-builder.ts +22 -2
  81. package/src/activities/execute-deep-agent/setup.ts +40 -4
  82. package/src/client/stigmer-client.ts +11 -4
  83. package/src/main.ts +20 -0
  84. package/src/runner.ts +62 -0
  85. package/src/sandbox-token-renewal.ts +212 -0
  86. package/src/shared/__tests__/channel-attachment.test.ts +3 -3
  87. package/src/shared/__tests__/conversation-attachment.test.ts +138 -0
  88. package/src/shared/__tests__/conversation-catchup.test.ts +70 -0
  89. package/src/shared/__tests__/synthesized-attachment.test.ts +120 -0
  90. package/src/shared/artifact-storage.ts +32 -7
  91. package/src/shared/channel-attachment.ts +3 -1
  92. package/src/shared/conversation-attachment.ts +115 -0
  93. package/src/shared/conversation-catchup.ts +60 -0
  94. package/src/workflow-engine/__tests__/golden-execution.test.ts +8 -8
  95. package/src/workflow-engine/__tests__/loader.test.ts +192 -7
  96. package/src/workflow-engine/__tests__/tasks/call-agent.test.ts +9 -9
  97. package/src/workflow-engine/loader.ts +113 -2
  98. package/src/workflow-engine/tasks/call-agent.ts +0 -2
  99. package/src/workflow-engine/types.ts +40 -7
  100. package/src/workflows/call-agent-orchestrator.ts +8 -2
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Unit tests for the conversation-catchup module (cloud channel-conversations
3
+ * DD-006, T03 Sitting 3). Unlike its metadata-keyed siblings there is no
4
+ * string key to mirror-guard — the value rides the typed
5
+ * `AgentExecutionSpec.conversation_catchup` proto field, so codegen enforces
6
+ * the cross-repo contract. What IS pinned here: the blank-is-absent read
7
+ * semantics (the field is present on EVERY channel turn for its watermark
8
+ * bookkeeping — only a non-empty digest means anything), and the framing's
9
+ * behavioral contract.
10
+ */
11
+
12
+ import { describe, it, expect } from "vitest";
13
+ import { create } from "@bufbuild/protobuf";
14
+ import { ConversationCatchupSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/spec_pb";
15
+ import { TimestampSchema } from "@bufbuild/protobuf/wkt";
16
+
17
+ import {
18
+ formatConversationCatchupText,
19
+ readConversationCatchup,
20
+ } from "../conversation-catchup.js";
21
+
22
+ const DIGEST =
23
+ "Customer: where is my order?\n"
24
+ + "Teammate: I've refunded you in full.\n"
25
+ + "You escalated: refund beyond policy";
26
+
27
+ describe("readConversationCatchup", () => {
28
+ it("reads a non-empty digest", () => {
29
+ const catchup = create(ConversationCatchupSchema, { digest: DIGEST });
30
+ expect(readConversationCatchup(catchup)).toBe(DIGEST);
31
+ });
32
+
33
+ it("answers undefined when the field is absent", () => {
34
+ expect(readConversationCatchup(undefined)).toBeUndefined();
35
+ });
36
+
37
+ it("a blank digest is no catchup — window_end alone is cloud bookkeeping, never a reason to inject", () => {
38
+ // A21: the field rides EVERY channel turn so the watermark can advance;
39
+ // most turns carry an empty digest. The runner must render nothing.
40
+ const catchup = create(ConversationCatchupSchema, {
41
+ digest: " ",
42
+ windowEnd: create(TimestampSchema, { seconds: 1_775_000_000n }),
43
+ });
44
+ expect(readConversationCatchup(catchup)).toBeUndefined();
45
+ });
46
+ });
47
+
48
+ describe("formatConversationCatchupText", () => {
49
+ const framed = formatConversationCatchupText(DIGEST);
50
+
51
+ it("frames the digest as known history the agent must not answer or announce", () => {
52
+ expect(framed).toContain("you have not seen");
53
+ expect(framed).toContain("do not answer or re-answer");
54
+ expect(framed).toContain("do not repeat or summarize them back");
55
+ expect(framed).toContain("Continue from the customer's newest message.");
56
+ });
57
+
58
+ it("asserts no takeover — a digest can exist with no human handoff at all (the A15/A20 honesty bar)", () => {
59
+ // The preamble may DESCRIBE what the digest can contain ("may include"),
60
+ // but must never state that a handoff happened on THIS conversation: a
61
+ // failed turn's re-composed window has no teammate in it anywhere.
62
+ expect(framed).toContain("may include");
63
+ expect(framed).not.toContain("stepped in");
64
+ expect(framed).not.toContain("took over");
65
+ });
66
+
67
+ it("ends with the digest — the preamble precedes, nothing trails", () => {
68
+ expect(framed.endsWith(DIGEST)).toBe(true);
69
+ });
70
+ });
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Composition of ALL THREE synthesized attachments through
3
+ * injectSynthesizedAttachment — the first test to chain them the way
4
+ * both harness call sites do (datastore, then channels, then
5
+ * conversation, each after resolve + backfill). Per-slug independence
6
+ * is the property that makes a third attachment safe to add: replacing
7
+ * one reserved slug must never disturb its siblings.
8
+ */
9
+
10
+ import { describe, expect, it, vi } from "vitest";
11
+ import type { MessagingChannel } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/message_io_pb";
12
+ import { create } from "@bufbuild/protobuf";
13
+ import { DatastoreUsageSchema } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/spec_pb";
14
+ import { ApiResourceReferenceSchema } from "@stigmer/protos/ai/stigmer/commons/apiresource/io_pb";
15
+
16
+ import {
17
+ CHANNEL_ATTACHMENT_SLUG,
18
+ synthesizeChannelAttachment,
19
+ } from "../channel-attachment.js";
20
+ import {
21
+ CONVERSATION_ATTACHMENT_SLUG,
22
+ synthesizeConversationAttachment,
23
+ } from "../conversation-attachment.js";
24
+ import {
25
+ DATASTORE_ATTACHMENT_SLUG,
26
+ synthesizeDatastoreAttachment,
27
+ } from "../datastore-attachment.js";
28
+ import { injectSynthesizedAttachment } from "../synthesized-attachment.js";
29
+ import type { ResolvedMcpServer } from "../mcp-resolver.js";
30
+
31
+ const options = {
32
+ bridgeEndpoint: "https://mcp.stigmer.ai",
33
+ credential: "sandbox-token",
34
+ backendEndpoint: "http://localhost:7234",
35
+ };
36
+
37
+ const userServer: ResolvedMcpServer = {
38
+ slug: "github",
39
+ connectionType: "http",
40
+ url: "https://example.com",
41
+ toolApprovals: [],
42
+ pinnedToolApprovals: [],
43
+ discoveredCapabilitiesEmpty: false,
44
+ };
45
+
46
+ function allThree(): ResolvedMcpServer[] {
47
+ const datastore = synthesizeDatastoreAttachment(
48
+ [create(DatastoreUsageSchema, {
49
+ datastoreRef: create(ApiResourceReferenceSchema, { slug: "clinic" }),
50
+ })],
51
+ options,
52
+ )!;
53
+ const channels = synthesizeChannelAttachment(
54
+ [{ channel: { channel: "isc-whatsapp", provider: "whatsapp" } as MessagingChannel, templates: [] }],
55
+ options,
56
+ )!;
57
+ const conversation = synthesizeConversationAttachment("agch_1", options)!;
58
+
59
+ // The harness order at both call sites: datastore, channels, conversation.
60
+ let servers = injectSynthesizedAttachment([userServer], datastore, "datastore records");
61
+ servers = injectSynthesizedAttachment(servers, channels, "channel messaging");
62
+ return injectSynthesizedAttachment(servers, conversation, "conversation participation");
63
+ }
64
+
65
+ describe("three synthesized attachments in one chain", () => {
66
+ it("composes all three after the user's servers, in injection order", () => {
67
+ expect(allThree().map((s) => s.slug)).toEqual([
68
+ "github",
69
+ DATASTORE_ATTACHMENT_SLUG,
70
+ CHANNEL_ATTACHMENT_SLUG,
71
+ CONVERSATION_ATTACHMENT_SLUG,
72
+ ]);
73
+ });
74
+
75
+ it("each rides its own bridge route with the shared credential", () => {
76
+ const bySlug = new Map(allThree().map((s) => [s.slug, s]));
77
+ expect(bySlug.get(DATASTORE_ATTACHMENT_SLUG)?.url).toBe("https://mcp.stigmer.ai/records");
78
+ expect(bySlug.get(CHANNEL_ATTACHMENT_SLUG)?.url).toBe("https://mcp.stigmer.ai/channels");
79
+ expect(bySlug.get(CONVERSATION_ATTACHMENT_SLUG)?.url).toBe(
80
+ "https://mcp.stigmer.ai/conversation",
81
+ );
82
+ for (const slug of [
83
+ DATASTORE_ATTACHMENT_SLUG,
84
+ CHANNEL_ATTACHMENT_SLUG,
85
+ CONVERSATION_ATTACHMENT_SLUG,
86
+ ]) {
87
+ expect(bySlug.get(slug)?.headers).toEqual({ Authorization: "Bearer sandbox-token" });
88
+ }
89
+ });
90
+
91
+ it("replacing one shadowed reserved slug never disturbs the siblings", () => {
92
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
93
+ const impostor: ResolvedMcpServer = {
94
+ ...userServer,
95
+ slug: CONVERSATION_ATTACHMENT_SLUG,
96
+ url: "https://evil.example.com",
97
+ };
98
+ const conversation = synthesizeConversationAttachment("agch_1", options)!;
99
+ const datastore = synthesizeDatastoreAttachment(
100
+ [create(DatastoreUsageSchema, {
101
+ datastoreRef: create(ApiResourceReferenceSchema, { slug: "clinic" }),
102
+ })],
103
+ options,
104
+ )!;
105
+
106
+ let servers = injectSynthesizedAttachment([impostor, userServer], datastore, "datastore records");
107
+ servers = injectSynthesizedAttachment(servers, conversation, "conversation participation");
108
+
109
+ expect(servers.map((s) => s.slug)).toEqual([
110
+ "github",
111
+ DATASTORE_ATTACHMENT_SLUG,
112
+ CONVERSATION_ATTACHMENT_SLUG,
113
+ ]);
114
+ expect(servers.find((s) => s.slug === CONVERSATION_ATTACHMENT_SLUG)?.url).toBe(
115
+ "https://mcp.stigmer.ai/conversation",
116
+ );
117
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("reserved"));
118
+ warnSpy.mockRestore();
119
+ });
120
+ });
@@ -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 authToken: string;
92
+ private readonly authTokenSource: ProxyAuthTokenSource;
93
93
 
94
- constructor(proxyEndpoint: string, authToken: string) {
94
+ constructor(proxyEndpoint: string, authToken: ProxyAuthTokenSource) {
95
95
  this.baseUrl = `${proxyEndpoint.replace(/\/+$/, "")}/v1/proxy/artifacts`;
96
- this.authToken = authToken;
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: string | null;
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
- proxyAuthToken: type === "proxy" ? (config.stigmerToken ?? null) : null,
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
- if (!cfg.proxyAuthToken) {
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);
@@ -47,7 +47,9 @@ import { grpcTarget, type SynthesizedAttachmentOptions } from "./synthesized-att
47
47
  /**
48
48
  * The synthesized attachment's slug. Reserved: a user McpServer with
49
49
  * this slug is shadowed by the synthesized attachment, with a warning.
50
- * Pinned cross-repo by the mcp-server integration test (the
50
+ * Runner-internal (the resolved-server name and shadow key — the
51
+ * mcp-server never sees it); pinned by this module's test. The ROUTE
52
+ * below is the cross-repo string, pinned on both sides (the
51
53
  * TOOL_CALL_LIMIT precedent).
52
54
  */
53
55
  export const CHANNEL_ATTACHMENT_SLUG = "stigmer-channels";
@@ -0,0 +1,115 @@
1
+ /**
2
+ * The runner-synthesized conversation participation attachment
3
+ * (channel-conversations DD-008 D-c, A14) — the third synthesized
4
+ * attachment, on the datastore module's shape (a cheap local predicate,
5
+ * not the channel module's discovery machinery).
6
+ *
7
+ * When the session IS a live channel conversation, the runner
8
+ * synthesizes ONE MCP attachment serving `escalate_to_human`, so the
9
+ * agent can flag its own conversation for human attention
10
+ * (escalate-and-continue: the agent keeps serving; nothing is paged).
11
+ *
12
+ * The conditioning signal is the session resource label
13
+ * `stigmer.ai/channel-id`, stamped server-side from the JWT on every
14
+ * channel-created Session (ChannelSessionCreateScopeStep) — the same
15
+ * field the cloud's own ChannelMessagingReach.deriveOrigin reads to
16
+ * answer exactly this question. It is deliberately NOT a
17
+ * SessionSpec.metadata key: none of those asserts "this is a channel
18
+ * conversation" (sender identity is who wrote, the bridge is rollover
19
+ * provenance), and a new key would reach existing live conversations
20
+ * only at rollover. The label is not authorization — a spoofed label
21
+ * buys a tool the server refuses (the reach derives identity from the
22
+ * session token, never from labels the runner read).
23
+ *
24
+ * ONE connection shape — HTTP against the bridge's /conversation route
25
+ * with the execution's session-scoped credential as the Bearer token —
26
+ * and deliberately NO stdio fallback, diverging from both siblings:
27
+ * escalate is cloud-only (OSS refuses FAILED_PRECONDITION) AND
28
+ * session-token-only (a stdio child's startup API key carries no
29
+ * session_id claim, so even cloud would refuse PERMISSION_DENIED). A
30
+ * stdio shape would be a tool that can only fail; no bridge endpoint
31
+ * means honest absence instead.
32
+ *
33
+ * Also deliberately NO prompt section (the siblings' <available_*>
34
+ * pattern): the tool description carries the full when-to-use contract,
35
+ * and a standing section would spend every channel turn's context to
36
+ * restate what the tool listing already shows.
37
+ *
38
+ * Approval-free by construction, and FORCED, not convenient: channel
39
+ * surfaces run APPROVAL_MODE_UNATTENDED, where a gated tool resolves as
40
+ * skip-and-adapt — a gated escalation would never fire (DD-008's
41
+ * approval-free ruling). Empty approval maps + no McpServerUsage keep
42
+ * the connect backfill structurally unable to gate it (see
43
+ * synthesized-attachment.ts). Callers inject AFTER resolve + backfill.
44
+ */
45
+
46
+ import type { ResolvedMcpServer } from "./mcp-resolver.js";
47
+ import type { SynthesizedAttachmentOptions } from "./synthesized-attachment.js";
48
+
49
+ /**
50
+ * The synthesized attachment's slug. Reserved: a user McpServer with
51
+ * this slug is shadowed by the synthesized attachment, with a warning.
52
+ * Runner-internal (the resolved-server name and shadow key — the
53
+ * mcp-server never sees it); pinned by this module's test.
54
+ */
55
+ export const CONVERSATION_ATTACHMENT_SLUG = "stigmer-conversation";
56
+
57
+ /**
58
+ * The bridge route serving the conversation-only roster. The cross-repo
59
+ * string: pinned here and in the mcp-server's conversation integration
60
+ * test — a drift strands every synthesized attachment on a 404.
61
+ */
62
+ export const CONVERSATION_ROUTE = "/conversation";
63
+
64
+ /**
65
+ * The session label naming the serving channel. Pinned verbatim to
66
+ * ChannelRuntimeConstants.CHANNEL_ID_METADATA_KEY in stigmer-cloud
67
+ * (mirror guard in this module's test and in ChannelSessionBrokerTest).
68
+ * Drift degrades to honest absence — the tool silently stops attaching,
69
+ * escalation never fires from a tool that was never offered — never
70
+ * worse.
71
+ */
72
+ export const CHANNEL_ID_LABEL = "stigmer.ai/channel-id";
73
+
74
+ /**
75
+ * Read the serving channel id from a session's resource labels. Blank
76
+ * and whitespace-only values are absent: the label is stamped complete
77
+ * or not at all, and a blank channel id must not synthesize a tool.
78
+ */
79
+ export function readChannelConversationId(
80
+ labels: Record<string, string> | undefined,
81
+ ): string | undefined {
82
+ const channelId = labels?.[CHANNEL_ID_LABEL]?.trim();
83
+ return channelId !== undefined && channelId !== "" ? channelId : undefined;
84
+ }
85
+
86
+ /**
87
+ * Synthesize the conversation attachment for a channel-conversation
88
+ * session. Returns undefined when the session serves no channel
89
+ * conversation OR no bridge endpoint is configured (the deliberate
90
+ * no-stdio divergence — see the file header).
91
+ */
92
+ export function synthesizeConversationAttachment(
93
+ channelId: string | undefined,
94
+ options: SynthesizedAttachmentOptions,
95
+ ): ResolvedMcpServer | undefined {
96
+ if (channelId === undefined) {
97
+ return undefined;
98
+ }
99
+ if (options.bridgeEndpoint === null || options.bridgeEndpoint === "") {
100
+ return undefined;
101
+ }
102
+
103
+ // Approval-free by construction + backfill-proof: see file header.
104
+ return {
105
+ slug: CONVERSATION_ATTACHMENT_SLUG,
106
+ toolApprovals: [],
107
+ pinnedToolApprovals: [],
108
+ discoveredCapabilitiesEmpty: false,
109
+ connectionType: "http",
110
+ url: options.bridgeEndpoint.replace(/\/+$/, "") + CONVERSATION_ROUTE,
111
+ headers: options.credential !== null && options.credential !== ""
112
+ ? { Authorization: `Bearer ${options.credential}` }
113
+ : undefined,
114
+ };
115
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Conversation catchup (cloud channel-conversations DD-006): what happened on
3
+ * a live channel conversation that the agent has not seen — customer messages
4
+ * handled by a human teammate, the teammate's replies, platform notices the
5
+ * customer received, notes, and the agent's own earlier escalations.
6
+ *
7
+ * The cloud composes the CONTENT (bare `Customer:` / `Teammate:` / `System:` /
8
+ * `You escalated:` / `Note:` lines, oldest first) on the execution spec's
9
+ * `conversation_catchup` field, fresh per turn. This module owns the
10
+ * PRESENTATION framing; the digest is prepended to the TURN'S USER MESSAGE on
11
+ * both harnesses (A27) — never the system prompt — because it is per-turn
12
+ * conversation content that must persist in the conversation history: the
13
+ * native system prompt is rebuilt per invocation and would forget the digest
14
+ * one turn later, while a message rides the checkpointer/agent store forever.
15
+ *
16
+ * Unlike its metadata-keyed siblings (context-bridge, sender-identity,
17
+ * session-context) there is no string key to mirror-guard: the value rides a
18
+ * TYPED proto field, so codegen enforces the cross-repo contract. The
19
+ * degradation posture still holds — an absent or blank digest renders
20
+ * nothing, and a runner predating this module simply ignores the field: the
21
+ * agent re-enters blind, exactly the pre-DD-006 behavior, never worse.
22
+ */
23
+
24
+ import type { ConversationCatchup } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/spec_pb";
25
+
26
+ /**
27
+ * How the digest is introduced to the model, shared by both harnesses so the
28
+ * behavioral contract ("known history, don't answer or announce it") cannot
29
+ * drift between them. Deliberately takeover-neutral: a digest can exist with
30
+ * no human handoff at all (a failed turn's re-composed window), so the
31
+ * preamble asserts only what is always true (the A15/A20 honesty bar).
32
+ */
33
+ const CONVERSATION_CATCHUP_PREAMBLE =
34
+ "Below is activity from this conversation that you have not seen — " +
35
+ "oldest first. It may include customer messages that were handled by a " +
36
+ "human teammate, the teammate's own replies, notices the customer " +
37
+ "received, internal notes, and escalations you raised earlier. Treat it " +
38
+ "as conversation history you already know: do not answer or re-answer " +
39
+ "these messages, do not repeat or summarize them back, and do not " +
40
+ "mention any handoff unless asked. Continue from the customer's newest " +
41
+ "message.";
42
+
43
+ /**
44
+ * Read the catchup digest from an execution spec's `conversation_catchup`.
45
+ * Returns undefined when the field is absent or the digest is blank — the
46
+ * caller renders no section. The field itself is present on EVERY channel
47
+ * turn (its `window_end` is cloud watermark bookkeeping this module must
48
+ * never read); only a non-empty digest means there is something to say.
49
+ */
50
+ export function readConversationCatchup(
51
+ catchup: ConversationCatchup | undefined,
52
+ ): string | undefined {
53
+ const digest = catchup?.digest?.trim();
54
+ return digest ? digest : undefined;
55
+ }
56
+
57
+ /** The framed catchup body (preamble + digest), ready for section wrapping. */
58
+ export function formatConversationCatchupText(digest: string): string {
59
+ return `${CONVERSATION_CATCHUP_PREAMBLE}\n\n${digest.trim()}`;
60
+ }
@@ -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.config?.model).toBe("claude-3-5-sonnet");
408
- expect(config.config?.timeout).toBe(300);
409
- expect(config.config?.temperature).toBe(0.2);
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
- // Config and harness preserved
1052
- expect(config.config?.model).toBe("claude-sonnet-4");
1053
- expect(config.config?.timeout).toBe(300);
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