@stigmer/runner 3.7.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.
- package/dist/.build-fingerprint +1 -1
- package/dist/activities/execute-cursor/index.d.ts +12 -0
- package/dist/activities/execute-cursor/index.js +41 -5
- package/dist/activities/execute-cursor/index.js.map +1 -1
- package/dist/activities/execute-cursor/prompt-builder.d.ts +11 -0
- package/dist/activities/execute-cursor/prompt-builder.js +11 -0
- package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
- package/dist/activities/execute-deep-agent/mcp-gate.d.ts +28 -0
- package/dist/activities/execute-deep-agent/mcp-gate.js +22 -0
- package/dist/activities/execute-deep-agent/mcp-gate.js.map +1 -0
- package/dist/activities/execute-deep-agent/prompt-builder.d.ts +11 -0
- package/dist/activities/execute-deep-agent/prompt-builder.js +16 -0
- package/dist/activities/execute-deep-agent/prompt-builder.js.map +1 -1
- package/dist/activities/execute-deep-agent/setup.js +30 -4
- package/dist/activities/execute-deep-agent/setup.js.map +1 -1
- package/dist/shared/channel-attachment.d.ts +3 -1
- package/dist/shared/channel-attachment.js +3 -1
- package/dist/shared/channel-attachment.js.map +1 -1
- package/dist/shared/conversation-attachment.d.ts +81 -0
- package/dist/shared/conversation-attachment.js +102 -0
- package/dist/shared/conversation-attachment.js.map +1 -0
- package/dist/shared/conversation-catchup.d.ts +33 -0
- package/dist/shared/conversation-catchup.js +53 -0
- package/dist/shared/conversation-catchup.js.map +1 -0
- package/package.json +2 -2
- package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +79 -0
- package/src/activities/execute-cursor/index.ts +62 -4
- package/src/activities/execute-cursor/prompt-builder.ts +23 -0
- package/src/activities/execute-deep-agent/__tests__/mcp-gate.test.ts +42 -0
- package/src/activities/execute-deep-agent/__tests__/prompt-builder.test.ts +39 -1
- package/src/activities/execute-deep-agent/mcp-gate.ts +37 -0
- package/src/activities/execute-deep-agent/prompt-builder.ts +22 -2
- package/src/activities/execute-deep-agent/setup.ts +40 -4
- package/src/shared/__tests__/channel-attachment.test.ts +3 -3
- package/src/shared/__tests__/conversation-attachment.test.ts +138 -0
- package/src/shared/__tests__/conversation-catchup.test.ts +70 -0
- package/src/shared/__tests__/synthesized-attachment.test.ts +120 -0
- package/src/shared/channel-attachment.ts +3 -1
- package/src/shared/conversation-attachment.ts +115 -0
- package/src/shared/conversation-catchup.ts +60 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The conversation participation attachment (channel-conversations
|
|
3
|
+
* DD-008 D-c, A14): the label-keyed attachment decision, the HTTP-only
|
|
4
|
+
* shape (the deliberate no-stdio divergence from both siblings), the
|
|
5
|
+
* structural approval-freedom, and the pinned strings. The route is the
|
|
6
|
+
* cross-repo string — pinned here and in the mcp-server's conversation
|
|
7
|
+
* integration test (the TOOL_CALL_LIMIT precedent); the label is pinned
|
|
8
|
+
* here and in the cloud's ChannelSessionBrokerTest (the sender-identity
|
|
9
|
+
* mirror-guard precedent).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, expect, it } from "vitest";
|
|
13
|
+
|
|
14
|
+
import { mergeApprovalPolicies, type ActiveLeases } from "../approval-policy.js";
|
|
15
|
+
import { needsBackfill } from "../connect-backfill.js";
|
|
16
|
+
import {
|
|
17
|
+
CHANNEL_ID_LABEL,
|
|
18
|
+
CONVERSATION_ATTACHMENT_SLUG,
|
|
19
|
+
CONVERSATION_ROUTE,
|
|
20
|
+
readChannelConversationId,
|
|
21
|
+
synthesizeConversationAttachment,
|
|
22
|
+
} from "../conversation-attachment.js";
|
|
23
|
+
|
|
24
|
+
const noLeases: ActiveLeases = {
|
|
25
|
+
global: false,
|
|
26
|
+
categories: new Set(),
|
|
27
|
+
servers: new Set(),
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const cloudOptions = {
|
|
31
|
+
bridgeEndpoint: "https://mcp.stigmer.ai",
|
|
32
|
+
credential: "sandbox-token",
|
|
33
|
+
backendEndpoint: "http://localhost:7234",
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
describe("cross-repo and cross-file pinned strings", () => {
|
|
37
|
+
it("pins the label verbatim to the cloud's ChannelRuntimeConstants (mirror guard)", () => {
|
|
38
|
+
// Pinned to ChannelRuntimeConstants.CHANNEL_ID_METADATA_KEY in
|
|
39
|
+
// stigmer-cloud (ChannelSessionCreateScopeStep stamps it; the
|
|
40
|
+
// mirror-guard test lives in ChannelSessionBrokerTest). Drift
|
|
41
|
+
// degrades to honest absence — the escalation tool silently stops
|
|
42
|
+
// attaching — never worse; change BOTH sides together.
|
|
43
|
+
expect(CHANNEL_ID_LABEL).toBe("stigmer.ai/channel-id");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("pins the attachment slug and the bridge route", () => {
|
|
47
|
+
// The slug is runner-internal (the resolved-server name and shadow
|
|
48
|
+
// key). The route is cross-repo: the mcp-server's conversation
|
|
49
|
+
// integration test pins CONVERSATION_ROUTE independently — a drift
|
|
50
|
+
// strands every synthesized attachment on a 404.
|
|
51
|
+
expect(CONVERSATION_ATTACHMENT_SLUG).toBe("stigmer-conversation");
|
|
52
|
+
expect(CONVERSATION_ROUTE).toBe("/conversation");
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("readChannelConversationId", () => {
|
|
57
|
+
it("reads the serving channel id from the session labels", () => {
|
|
58
|
+
expect(readChannelConversationId({ [CHANNEL_ID_LABEL]: "agch_1" })).toBe("agch_1");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("treats missing labels, a missing key, and blank values as absent", () => {
|
|
62
|
+
expect(readChannelConversationId(undefined)).toBeUndefined();
|
|
63
|
+
expect(readChannelConversationId({})).toBeUndefined();
|
|
64
|
+
expect(readChannelConversationId({ [CHANNEL_ID_LABEL]: "" })).toBeUndefined();
|
|
65
|
+
expect(readChannelConversationId({ [CHANNEL_ID_LABEL]: " " })).toBeUndefined();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("ignores unrelated labels", () => {
|
|
69
|
+
expect(
|
|
70
|
+
readChannelConversationId({ "stigmer.ai/channel-conversation-key": "919000000001" }),
|
|
71
|
+
).toBeUndefined();
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("synthesizeConversationAttachment", () => {
|
|
76
|
+
it("returns undefined when the session serves no channel conversation", () => {
|
|
77
|
+
expect(synthesizeConversationAttachment(undefined, cloudOptions)).toBeUndefined();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("returns undefined without a bridge endpoint — HTTP-only, no stdio fallback", () => {
|
|
81
|
+
// The deliberate divergence from both sibling attachments: escalate
|
|
82
|
+
// is cloud-only (OSS refuses FAILED_PRECONDITION) AND
|
|
83
|
+
// session-token-only (a stdio child's API key carries no session_id
|
|
84
|
+
// claim), so a stdio shape could only ever fail. Honest absence.
|
|
85
|
+
expect(
|
|
86
|
+
synthesizeConversationAttachment("agch_1", {
|
|
87
|
+
bridgeEndpoint: null,
|
|
88
|
+
credential: "sandbox-token",
|
|
89
|
+
backendEndpoint: "http://localhost:7234",
|
|
90
|
+
}),
|
|
91
|
+
).toBeUndefined();
|
|
92
|
+
expect(
|
|
93
|
+
synthesizeConversationAttachment("agch_1", {
|
|
94
|
+
bridgeEndpoint: "",
|
|
95
|
+
credential: "sandbox-token",
|
|
96
|
+
backendEndpoint: "http://localhost:7234",
|
|
97
|
+
}),
|
|
98
|
+
).toBeUndefined();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("builds the HTTP shape against the bridge /conversation route with the credential", () => {
|
|
102
|
+
const attachment = synthesizeConversationAttachment("agch_1", {
|
|
103
|
+
...cloudOptions,
|
|
104
|
+
bridgeEndpoint: "https://mcp.stigmer.ai/",
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
expect(attachment).toMatchObject({
|
|
108
|
+
slug: CONVERSATION_ATTACHMENT_SLUG,
|
|
109
|
+
connectionType: "http",
|
|
110
|
+
url: "https://mcp.stigmer.ai/conversation",
|
|
111
|
+
headers: { Authorization: "Bearer sandbox-token" },
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("omits the Authorization header without a credential", () => {
|
|
116
|
+
const attachment = synthesizeConversationAttachment("agch_1", {
|
|
117
|
+
...cloudOptions,
|
|
118
|
+
credential: null,
|
|
119
|
+
});
|
|
120
|
+
expect(attachment?.headers).toBeUndefined();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("is approval-free by construction: zero entries in the merged approval map", () => {
|
|
124
|
+
const attachment = synthesizeConversationAttachment("agch_1", cloudOptions)!;
|
|
125
|
+
|
|
126
|
+
// Channel surfaces run APPROVAL_MODE_UNATTENDED, where a gated tool
|
|
127
|
+
// resolves as skip-and-adapt — a gated escalation would never fire
|
|
128
|
+
// (DD-008's approval-free ruling, the DD-001 SD-3 structural bypass).
|
|
129
|
+
const merged = mergeApprovalPolicies([attachment], [], noLeases);
|
|
130
|
+
expect(merged.size).toBe(0);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("is structurally immune to the connect backfill", () => {
|
|
134
|
+
const attachment = synthesizeConversationAttachment("agch_1", cloudOptions)!;
|
|
135
|
+
expect(attachment.discoveredCapabilitiesEmpty).toBe(false);
|
|
136
|
+
expect(needsBackfill(attachment)).toBe(false);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -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
|
-
*
|
|
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
|
+
}
|