@stigmer/runner 3.12.7 → 3.12.9
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/fetch-interceptor.js +29 -16
- package/dist/activities/execute-cursor/fetch-interceptor.js.map +1 -1
- package/dist/activities/execute-cursor/index.d.ts +14 -0
- package/dist/activities/execute-cursor/index.js +76 -5
- package/dist/activities/execute-cursor/index.js.map +1 -1
- package/dist/activities/execute-cursor/message-translator.d.ts +15 -9
- package/dist/activities/execute-cursor/message-translator.js +15 -9
- package/dist/activities/execute-cursor/message-translator.js.map +1 -1
- package/dist/activities/execute-deep-agent/index.js +9 -0
- package/dist/activities/execute-deep-agent/index.js.map +1 -1
- package/dist/activities/execute-deep-agent/mcp-gate.d.ts +2 -0
- package/dist/activities/execute-deep-agent/mcp-gate.js +2 -1
- package/dist/activities/execute-deep-agent/mcp-gate.js.map +1 -1
- package/dist/activities/execute-deep-agent/setup.d.ts +10 -1
- package/dist/activities/execute-deep-agent/setup.js +45 -3
- package/dist/activities/execute-deep-agent/setup.js.map +1 -1
- package/dist/payload-codecs.d.ts +1 -1
- package/dist/payload-codecs.js +6 -4
- package/dist/payload-codecs.js.map +1 -1
- package/dist/shared/memory-attachment.d.ts +97 -0
- package/dist/shared/memory-attachment.js +136 -0
- package/dist/shared/memory-attachment.js.map +1 -0
- package/dist/shared/memory-retrieval.d.ts +126 -0
- package/dist/shared/memory-retrieval.js +293 -0
- package/dist/shared/memory-retrieval.js.map +1 -0
- package/dist/shared/runner-credential-keys.js +2 -1
- package/dist/shared/runner-credential-keys.js.map +1 -1
- package/dist/shared/tool-kind.js +9 -0
- package/dist/shared/tool-kind.js.map +1 -1
- package/package.json +3 -2
- package/src/__tests__/history-encryption-e2e.test.ts +1 -1
- package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +40 -1
- package/src/activities/execute-cursor/__tests__/fetch-interceptor.test.ts +49 -15
- package/src/activities/execute-cursor/fetch-interceptor.ts +37 -16
- package/src/activities/execute-cursor/index.ts +86 -5
- package/src/activities/execute-cursor/message-translator.ts +15 -9
- package/src/activities/execute-deep-agent/__tests__/mcp-gate.test.ts +7 -0
- package/src/activities/execute-deep-agent/index.ts +10 -0
- package/src/activities/execute-deep-agent/mcp-gate.ts +4 -1
- package/src/activities/execute-deep-agent/setup.ts +70 -6
- package/src/payload-codecs.ts +12 -9
- package/src/shared/__tests__/memory-attachment.test.ts +167 -0
- package/src/shared/__tests__/memory-retrieval.test.ts +310 -0
- package/src/shared/memory-attachment.ts +167 -0
- package/src/shared/memory-retrieval.ts +383 -0
- package/src/shared/runner-credential-keys.ts +2 -1
- package/src/shared/tool-kind.ts +10 -0
- package/dist/claimcheck/compressor.d.ts +0 -2
- package/dist/claimcheck/compressor.js +0 -8
- package/dist/claimcheck/compressor.js.map +0 -1
- package/dist/claimcheck/config.d.ts +0 -7
- package/dist/claimcheck/config.js +0 -10
- package/dist/claimcheck/config.js.map +0 -1
- package/dist/claimcheck/index.d.ts +0 -3
- package/dist/claimcheck/index.js +0 -4
- package/dist/claimcheck/index.js.map +0 -1
- package/dist/claimcheck/payload-codec.d.ts +0 -23
- package/dist/claimcheck/payload-codec.js +0 -105
- package/dist/claimcheck/payload-codec.js.map +0 -1
- package/dist/encryption/config.d.ts +0 -64
- package/dist/encryption/config.js +0 -113
- package/dist/encryption/config.js.map +0 -1
- package/dist/encryption/index.d.ts +0 -3
- package/dist/encryption/index.js +0 -3
- package/dist/encryption/index.js.map +0 -1
- package/dist/encryption/payload-codec.d.ts +0 -41
- package/dist/encryption/payload-codec.js +0 -130
- package/dist/encryption/payload-codec.js.map +0 -1
- package/src/__tests__/claimcheck-codec.test.ts +0 -256
- package/src/__tests__/encryption-codec.test.ts +0 -287
- package/src/__tests__/fixtures/encrypted-payload-fixture.json +0 -15
- package/src/claimcheck/compressor.ts +0 -9
- package/src/claimcheck/config.ts +0 -20
- package/src/claimcheck/index.ts +0 -3
- package/src/claimcheck/payload-codec.ts +0 -139
- package/src/encryption/config.ts +0 -161
- package/src/encryption/index.ts +0 -3
- package/src/encryption/payload-codec.ts +0 -152
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The memory capture attachment (DD-005 D1): the recall-snapshot gate
|
|
3
|
+
* (enabled bit, zero-facts included), both connection shapes with the
|
|
4
|
+
* capture-context carriers, the structural approval-freedom of
|
|
5
|
+
* synthesized attachments, and the cross-repo string pins (route +
|
|
6
|
+
* context keys, guarded here and in the mcp-server memory integration
|
|
7
|
+
* test — the TOOL_CALL_LIMIT precedent). The slug and roster are
|
|
8
|
+
* runner-internal and guarded here alone.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { create } from "@bufbuild/protobuf";
|
|
12
|
+
import { describe, expect, it } from "vitest";
|
|
13
|
+
import { RecalledMemoriesSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/spec_pb";
|
|
14
|
+
|
|
15
|
+
import { mergeApprovalPolicies, type ActiveLeases } from "../approval-policy.js";
|
|
16
|
+
import { needsBackfill } from "../connect-backfill.js";
|
|
17
|
+
import {
|
|
18
|
+
MEMORY_AGENT_ID_ENV,
|
|
19
|
+
MEMORY_AGENT_ID_HEADER,
|
|
20
|
+
MEMORY_ATTACHMENT_SLUG,
|
|
21
|
+
MEMORY_EXECUTION_ID_ENV,
|
|
22
|
+
MEMORY_EXECUTION_ID_HEADER,
|
|
23
|
+
MEMORY_ORG_ENV,
|
|
24
|
+
MEMORY_ORG_HEADER,
|
|
25
|
+
MEMORY_ROUTE,
|
|
26
|
+
MEMORY_SESSION_ID_ENV,
|
|
27
|
+
MEMORY_SESSION_ID_HEADER,
|
|
28
|
+
memoryCaptureEnabled,
|
|
29
|
+
synthesizeMemoryAttachment,
|
|
30
|
+
type MemoryCaptureContext,
|
|
31
|
+
} from "../memory-attachment.js";
|
|
32
|
+
import type { ResolvedMcpServer } from "../mcp-resolver.js";
|
|
33
|
+
|
|
34
|
+
const context: MemoryCaptureContext = {
|
|
35
|
+
org: "acme",
|
|
36
|
+
agentId: "agt_1",
|
|
37
|
+
sessionId: "ses_1",
|
|
38
|
+
agentExecutionId: "aex_1",
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const cloudOptions = {
|
|
42
|
+
bridgeEndpoint: "https://mcp.stigmer.ai/",
|
|
43
|
+
credential: "sandbox-token",
|
|
44
|
+
backendEndpoint: "http://localhost:7234",
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const ossOptions = {
|
|
48
|
+
bridgeEndpoint: null,
|
|
49
|
+
credential: null,
|
|
50
|
+
backendEndpoint: "http://localhost:7234",
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const noLeases: ActiveLeases = {
|
|
54
|
+
global: false,
|
|
55
|
+
categories: new Set(),
|
|
56
|
+
servers: new Set(),
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
describe("memoryCaptureEnabled (the DD-005 D1 injection signal)", () => {
|
|
60
|
+
it("is the snapshot's enabled bit — absent and disabled read false", () => {
|
|
61
|
+
expect(memoryCaptureEnabled(undefined)).toBe(false);
|
|
62
|
+
expect(memoryCaptureEnabled(create(RecalledMemoriesSchema, { enabled: false }))).toBe(false);
|
|
63
|
+
expect(memoryCaptureEnabled(create(RecalledMemoriesSchema, { enabled: true }))).toBe(true);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("enabled with ZERO facts still offers the tool — memory on, nothing stored yet", () => {
|
|
67
|
+
const snapshot = create(RecalledMemoriesSchema, { enabled: true, facts: [] });
|
|
68
|
+
expect(memoryCaptureEnabled(snapshot)).toBe(true);
|
|
69
|
+
expect(synthesizeMemoryAttachment(snapshot, context, cloudOptions)).toBeDefined();
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("synthesizeMemoryAttachment", () => {
|
|
74
|
+
const enabled = create(RecalledMemoriesSchema, { enabled: true });
|
|
75
|
+
|
|
76
|
+
it("returns undefined when the snapshot is absent or disabled — honest absence", () => {
|
|
77
|
+
expect(synthesizeMemoryAttachment(undefined, context, cloudOptions)).toBeUndefined();
|
|
78
|
+
expect(
|
|
79
|
+
synthesizeMemoryAttachment(
|
|
80
|
+
create(RecalledMemoriesSchema, { enabled: false }),
|
|
81
|
+
context,
|
|
82
|
+
cloudOptions,
|
|
83
|
+
),
|
|
84
|
+
).toBeUndefined();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("builds the HTTP shape against the bridge /memory route with credential + context headers", () => {
|
|
88
|
+
const attachment = synthesizeMemoryAttachment(enabled, context, cloudOptions);
|
|
89
|
+
expect(attachment).toMatchObject({
|
|
90
|
+
slug: MEMORY_ATTACHMENT_SLUG,
|
|
91
|
+
connectionType: "http",
|
|
92
|
+
url: "https://mcp.stigmer.ai/memory",
|
|
93
|
+
headers: {
|
|
94
|
+
Authorization: "Bearer sandbox-token",
|
|
95
|
+
[MEMORY_ORG_HEADER]: "acme",
|
|
96
|
+
[MEMORY_AGENT_ID_HEADER]: "agt_1",
|
|
97
|
+
[MEMORY_SESSION_ID_HEADER]: "ses_1",
|
|
98
|
+
[MEMORY_EXECUTION_ID_HEADER]: "aex_1",
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("builds the OSS stdio shape: the CLI-embedded bridge with the memory roster + context env", () => {
|
|
104
|
+
const attachment = synthesizeMemoryAttachment(enabled, context, ossOptions);
|
|
105
|
+
expect(attachment).toMatchObject({
|
|
106
|
+
connectionType: "stdio",
|
|
107
|
+
command: "stigmer",
|
|
108
|
+
args: ["mcp-server"],
|
|
109
|
+
env: {
|
|
110
|
+
STIGMER_MCP_ROSTER: "memory",
|
|
111
|
+
STIGMER_SERVER_ADDRESS: "localhost:7234",
|
|
112
|
+
[MEMORY_ORG_ENV]: "acme",
|
|
113
|
+
[MEMORY_AGENT_ID_ENV]: "agt_1",
|
|
114
|
+
[MEMORY_SESSION_ID_ENV]: "ses_1",
|
|
115
|
+
[MEMORY_EXECUTION_ID_ENV]: "aex_1",
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("omits empty context fields from the carrier — best-effort attribution, never blank entries", () => {
|
|
121
|
+
const partial: MemoryCaptureContext = {
|
|
122
|
+
org: "acme",
|
|
123
|
+
agentId: "",
|
|
124
|
+
sessionId: "ses_1",
|
|
125
|
+
agentExecutionId: "",
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const http = synthesizeMemoryAttachment(enabled, partial, cloudOptions);
|
|
129
|
+
expect(Object.keys(http?.headers ?? {}).sort()).toEqual([
|
|
130
|
+
"Authorization",
|
|
131
|
+
MEMORY_ORG_HEADER,
|
|
132
|
+
MEMORY_SESSION_ID_HEADER,
|
|
133
|
+
].sort());
|
|
134
|
+
|
|
135
|
+
const stdio = synthesizeMemoryAttachment(enabled, partial, ossOptions);
|
|
136
|
+
expect(stdio?.env).not.toHaveProperty(MEMORY_AGENT_ID_ENV);
|
|
137
|
+
expect(stdio?.env).not.toHaveProperty(MEMORY_EXECUTION_ID_ENV);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("pins the cross-repo strings the mcp-server side guards too", () => {
|
|
141
|
+
expect(MEMORY_ATTACHMENT_SLUG).toBe("stigmer-memory");
|
|
142
|
+
expect(MEMORY_ROUTE).toBe("/memory");
|
|
143
|
+
expect(MEMORY_ORG_HEADER).toBe("x-stigmer-memory-org");
|
|
144
|
+
expect(MEMORY_AGENT_ID_HEADER).toBe("x-stigmer-memory-agent-id");
|
|
145
|
+
expect(MEMORY_SESSION_ID_HEADER).toBe("x-stigmer-memory-session-id");
|
|
146
|
+
expect(MEMORY_EXECUTION_ID_HEADER).toBe("x-stigmer-memory-execution-id");
|
|
147
|
+
expect(MEMORY_ORG_ENV).toBe("STIGMER_MEMORY_ORG");
|
|
148
|
+
expect(MEMORY_AGENT_ID_ENV).toBe("STIGMER_MEMORY_AGENT_ID");
|
|
149
|
+
expect(MEMORY_SESSION_ID_ENV).toBe("STIGMER_MEMORY_SESSION_ID");
|
|
150
|
+
expect(MEMORY_EXECUTION_ID_ENV).toBe("STIGMER_MEMORY_EXECUTION_ID");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("is approval-free by construction: zero entries in the merged approval map", () => {
|
|
154
|
+
// Consent is the confirm RPC, not tool approval (DD-005 D3): the
|
|
155
|
+
// tool only creates a proposal, so gating it would stack a second
|
|
156
|
+
// consent gate in front of the real one.
|
|
157
|
+
const attachment = synthesizeMemoryAttachment(enabled, context, cloudOptions)!;
|
|
158
|
+
const merged = mergeApprovalPolicies([attachment as ResolvedMcpServer], noLeases);
|
|
159
|
+
expect(merged.size).toBe(0);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("is structurally immune to the connect backfill (destructiveHint tightener)", () => {
|
|
163
|
+
const attachment = synthesizeMemoryAttachment(enabled, context, cloudOptions)!;
|
|
164
|
+
expect(attachment.discoveredCapabilitiesEmpty).toBe(false);
|
|
165
|
+
expect(needsBackfill(attachment)).toBe(false);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the memory-retrieval module (stigmer/stigmer#293 Phase 3a,
|
|
3
|
+
* DD-008). What is pinned here, path by path:
|
|
4
|
+
*
|
|
5
|
+
* - The activation threshold: selection runs ONLY above RETRIEVAL_K
|
|
6
|
+
* candidates; at or below it there is NO embeddings call (most users
|
|
7
|
+
* never touch the embeddings API — the DD-008 D3 activation contract).
|
|
8
|
+
* - Top-k correctness and the presentation contract: selection is by
|
|
9
|
+
* relevance, presentation is snapshot order, ties break toward the
|
|
10
|
+
* lower snapshot index (deterministic across invocations).
|
|
11
|
+
* - The failure posture: every degraded path — no embedder, embed error,
|
|
12
|
+
* malformed response — injects wholesale with an honest
|
|
13
|
+
* selection_active=false report, never a throw into prompt build.
|
|
14
|
+
* - The written-once replay: a prior report (either polarity) replays
|
|
15
|
+
* without an embeddings call, so a re-invocation can never inject a
|
|
16
|
+
* different subset than the first invocation did.
|
|
17
|
+
* - The report contract: a report exists exactly when facts are injected;
|
|
18
|
+
* no injection, no report (absent report = wholesale by construction).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
22
|
+
import { create } from "@bufbuild/protobuf";
|
|
23
|
+
import { RecalledMemoriesSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/spec_pb";
|
|
24
|
+
import { RecalledMemoriesReportSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
|
|
25
|
+
|
|
26
|
+
import {
|
|
27
|
+
selectRecalledFacts,
|
|
28
|
+
RETRIEVAL_K,
|
|
29
|
+
EMBEDDING_MODEL,
|
|
30
|
+
QUERY_MAX_CHARS,
|
|
31
|
+
type EmbedFn,
|
|
32
|
+
} from "../memory-retrieval.js";
|
|
33
|
+
|
|
34
|
+
const QUERY = "How should I deploy the payments service?";
|
|
35
|
+
|
|
36
|
+
/** Snapshot of `count` facts: mem_0..mem_{count-1}, "fact 0".."fact N". */
|
|
37
|
+
function snapshot(count: number) {
|
|
38
|
+
return create(RecalledMemoriesSchema, {
|
|
39
|
+
enabled: true,
|
|
40
|
+
facts: Array.from({ length: count }, (_, i) => ({
|
|
41
|
+
memoryId: `mem_${i}`,
|
|
42
|
+
content: `fact ${i}`,
|
|
43
|
+
})),
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A deterministic embedder over 2D unit vectors: the query embeds to
|
|
49
|
+
* [1, 0]; fact i embeds to an angle that grows with i, so relevance order
|
|
50
|
+
* is exactly snapshot order (fact 0 most similar) unless a test overrides
|
|
51
|
+
* per-input vectors.
|
|
52
|
+
*/
|
|
53
|
+
function angularEmbedder(overrides?: Map<string, number[]>): EmbedFn & {
|
|
54
|
+
calls: string[][];
|
|
55
|
+
} {
|
|
56
|
+
const calls: string[][] = [];
|
|
57
|
+
const fn: EmbedFn = async (inputs) => {
|
|
58
|
+
calls.push([...inputs]);
|
|
59
|
+
return inputs.map((input, position) => {
|
|
60
|
+
const override = overrides?.get(input);
|
|
61
|
+
if (override) return override;
|
|
62
|
+
if (position === 0) return [1, 0];
|
|
63
|
+
const angle = (position / (inputs.length + 1)) * (Math.PI / 2);
|
|
64
|
+
return [Math.cos(angle), Math.sin(angle)];
|
|
65
|
+
});
|
|
66
|
+
};
|
|
67
|
+
return Object.assign(fn, { calls });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function baseOptions(embed: EmbedFn) {
|
|
71
|
+
return {
|
|
72
|
+
proxyEndpoint: null,
|
|
73
|
+
stigmerToken: null,
|
|
74
|
+
executionId: "exec_test",
|
|
75
|
+
embed,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
afterEach(() => {
|
|
80
|
+
vi.unstubAllEnvs();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("selectRecalledFacts — no injection, no report", () => {
|
|
84
|
+
it("answers nothing for an absent snapshot (pre-Phase-2 executions)", async () => {
|
|
85
|
+
const embed = angularEmbedder();
|
|
86
|
+
const result = await selectRecalledFacts(undefined, QUERY, baseOptions(embed));
|
|
87
|
+
expect(result.content).toBeUndefined();
|
|
88
|
+
expect(result.report).toBeUndefined();
|
|
89
|
+
expect(embed.calls).toHaveLength(0);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("answers nothing when recall is disabled or enabled-with-zero-facts", async () => {
|
|
93
|
+
const embed = angularEmbedder();
|
|
94
|
+
const disabled = create(RecalledMemoriesSchema, {
|
|
95
|
+
enabled: false,
|
|
96
|
+
facts: [{ memoryId: "mem_0", content: "fact 0" }],
|
|
97
|
+
});
|
|
98
|
+
expect(
|
|
99
|
+
(await selectRecalledFacts(disabled, QUERY, baseOptions(embed))).report,
|
|
100
|
+
).toBeUndefined();
|
|
101
|
+
const empty = create(RecalledMemoriesSchema, { enabled: true });
|
|
102
|
+
expect(
|
|
103
|
+
(await selectRecalledFacts(empty, QUERY, baseOptions(embed))).report,
|
|
104
|
+
).toBeUndefined();
|
|
105
|
+
expect(embed.calls).toHaveLength(0);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe("selectRecalledFacts — activation threshold (DD-008 D3)", () => {
|
|
110
|
+
it("injects wholesale with NO embeddings call at exactly k candidates", async () => {
|
|
111
|
+
const embed = angularEmbedder();
|
|
112
|
+
const result = await selectRecalledFacts(
|
|
113
|
+
snapshot(RETRIEVAL_K),
|
|
114
|
+
QUERY,
|
|
115
|
+
baseOptions(embed),
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
expect(embed.calls).toHaveLength(0);
|
|
119
|
+
expect(result.content?.facts).toHaveLength(RETRIEVAL_K);
|
|
120
|
+
expect(result.report?.selectionActive).toBe(false);
|
|
121
|
+
expect(result.report?.injectedMemoryIds).toEqual([]);
|
|
122
|
+
expect(result.report?.embeddingModel).toBe("");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("activates selection at k+1: one batched call, k facts injected", async () => {
|
|
126
|
+
const embed = angularEmbedder();
|
|
127
|
+
const result = await selectRecalledFacts(
|
|
128
|
+
snapshot(RETRIEVAL_K + 1),
|
|
129
|
+
QUERY,
|
|
130
|
+
baseOptions(embed),
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
expect(embed.calls).toHaveLength(1);
|
|
134
|
+
expect(embed.calls[0]).toHaveLength(RETRIEVAL_K + 2); // query + all candidates
|
|
135
|
+
expect(embed.calls[0][0]).toBe(QUERY);
|
|
136
|
+
expect(result.content?.facts).toHaveLength(RETRIEVAL_K);
|
|
137
|
+
expect(result.report?.selectionActive).toBe(true);
|
|
138
|
+
expect(result.report?.injectedMemoryIds).toHaveLength(RETRIEVAL_K);
|
|
139
|
+
expect(result.report?.embeddingModel).toBe(EMBEDDING_MODEL);
|
|
140
|
+
// The angular embedder makes the LAST fact least relevant.
|
|
141
|
+
expect(result.report?.injectedMemoryIds).not.toContain(`mem_${RETRIEVAL_K}`);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("counts only renderable candidates toward the threshold (blank facts dropped, the read-boundary semantics)", async () => {
|
|
145
|
+
const embed = angularEmbedder();
|
|
146
|
+
const recalled = create(RecalledMemoriesSchema, {
|
|
147
|
+
enabled: true,
|
|
148
|
+
facts: [
|
|
149
|
+
...Array.from({ length: RETRIEVAL_K }, (_, i) => ({
|
|
150
|
+
memoryId: `mem_${i}`,
|
|
151
|
+
content: `fact ${i}`,
|
|
152
|
+
})),
|
|
153
|
+
{ memoryId: "mem_blank", content: " " },
|
|
154
|
+
],
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const result = await selectRecalledFacts(recalled, QUERY, baseOptions(embed));
|
|
158
|
+
|
|
159
|
+
expect(embed.calls).toHaveLength(0); // 20 renderable ≤ k → wholesale
|
|
160
|
+
expect(result.content?.facts).toHaveLength(RETRIEVAL_K);
|
|
161
|
+
expect(result.report?.selectionActive).toBe(false);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe("selectRecalledFacts — ranking and presentation", () => {
|
|
166
|
+
it("selects by relevance but presents in snapshot order, ids parallel to facts", async () => {
|
|
167
|
+
// Invert relevance: the LAST fact is the most similar, fact 0 the least.
|
|
168
|
+
const count = RETRIEVAL_K + 5;
|
|
169
|
+
const overrides = new Map<string, number[]>();
|
|
170
|
+
for (let i = 0; i < count; i++) {
|
|
171
|
+
const angle = ((count - i) / (count + 1)) * (Math.PI / 2);
|
|
172
|
+
overrides.set(`fact ${i}`, [Math.cos(angle), Math.sin(angle)]);
|
|
173
|
+
}
|
|
174
|
+
const embed = angularEmbedder(overrides);
|
|
175
|
+
|
|
176
|
+
const result = await selectRecalledFacts(
|
|
177
|
+
snapshot(count),
|
|
178
|
+
QUERY,
|
|
179
|
+
baseOptions(embed),
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
// The 5 least relevant under inversion are the FIRST five snapshot facts.
|
|
183
|
+
const expectedIndices = Array.from({ length: RETRIEVAL_K }, (_, i) => i + 5);
|
|
184
|
+
expect(result.content?.facts).toEqual(expectedIndices.map((i) => `fact ${i}`));
|
|
185
|
+
expect(result.report?.injectedMemoryIds).toEqual(
|
|
186
|
+
expectedIndices.map((i) => `mem_${i}`),
|
|
187
|
+
);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("breaks ties toward the lower snapshot index — equal scores never reorder across invocations", async () => {
|
|
191
|
+
// Every input embeds identically: all scores tie.
|
|
192
|
+
const count = RETRIEVAL_K + 3;
|
|
193
|
+
const overrides = new Map<string, number[]>([[QUERY, [1, 0]]]);
|
|
194
|
+
for (let i = 0; i < count; i++) {
|
|
195
|
+
overrides.set(`fact ${i}`, [1, 0]);
|
|
196
|
+
}
|
|
197
|
+
const embed = angularEmbedder(overrides);
|
|
198
|
+
|
|
199
|
+
const first = await selectRecalledFacts(snapshot(count), QUERY, baseOptions(embed));
|
|
200
|
+
const second = await selectRecalledFacts(snapshot(count), QUERY, baseOptions(embed));
|
|
201
|
+
|
|
202
|
+
const expected = Array.from({ length: RETRIEVAL_K }, (_, i) => `mem_${i}`);
|
|
203
|
+
expect(first.report?.injectedMemoryIds).toEqual(expected);
|
|
204
|
+
expect(second.report?.injectedMemoryIds).toEqual(expected);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("truncates the query at QUERY_MAX_CHARS — a giant paste must not 400 the whole batch", async () => {
|
|
208
|
+
const embed = angularEmbedder();
|
|
209
|
+
const giant = "x".repeat(QUERY_MAX_CHARS + 500);
|
|
210
|
+
|
|
211
|
+
await selectRecalledFacts(snapshot(RETRIEVAL_K + 1), giant, baseOptions(embed));
|
|
212
|
+
|
|
213
|
+
expect(embed.calls[0][0]).toHaveLength(QUERY_MAX_CHARS);
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
describe("selectRecalledFacts — failure posture (never worse than Phase 2)", () => {
|
|
218
|
+
it("degrades to wholesale with an honest report when the embedder throws", async () => {
|
|
219
|
+
const embed: EmbedFn = async () => {
|
|
220
|
+
throw new Error("connect ETIMEDOUT");
|
|
221
|
+
};
|
|
222
|
+
const result = await selectRecalledFacts(
|
|
223
|
+
snapshot(RETRIEVAL_K + 1),
|
|
224
|
+
QUERY,
|
|
225
|
+
baseOptions(embed),
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
expect(result.content?.facts).toHaveLength(RETRIEVAL_K + 1);
|
|
229
|
+
expect(result.report?.selectionActive).toBe(false);
|
|
230
|
+
expect(result.report?.injectedMemoryIds).toEqual([]);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("degrades to wholesale when the embedder answers the wrong vector count", async () => {
|
|
234
|
+
const embed: EmbedFn = async () => [[1, 0]];
|
|
235
|
+
const result = await selectRecalledFacts(
|
|
236
|
+
snapshot(RETRIEVAL_K + 1),
|
|
237
|
+
QUERY,
|
|
238
|
+
baseOptions(embed),
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
expect(result.content?.facts).toHaveLength(RETRIEVAL_K + 1);
|
|
242
|
+
expect(result.report?.selectionActive).toBe(false);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("injects wholesale when no embedder resolves (no proxy, no OpenAI key — the Anthropic-only OSS posture)", async () => {
|
|
246
|
+
vi.stubEnv("OPENAI_API_KEY", "");
|
|
247
|
+
|
|
248
|
+
const result = await selectRecalledFacts(snapshot(RETRIEVAL_K + 1), QUERY, {
|
|
249
|
+
proxyEndpoint: null,
|
|
250
|
+
stigmerToken: null,
|
|
251
|
+
executionId: "exec_test",
|
|
252
|
+
// No embed injected: the module resolves — and finds — nothing.
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
expect(result.content?.facts).toHaveLength(RETRIEVAL_K + 1);
|
|
256
|
+
expect(result.report?.selectionActive).toBe(false);
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
describe("selectRecalledFacts — written-once replay across re-invocations", () => {
|
|
261
|
+
it("replays a selection-active prior report without an embeddings call", async () => {
|
|
262
|
+
const embed = angularEmbedder();
|
|
263
|
+
const prior = create(RecalledMemoriesReportSchema, {
|
|
264
|
+
selectionActive: true,
|
|
265
|
+
injectedMemoryIds: ["mem_1", "mem_3"],
|
|
266
|
+
embeddingModel: EMBEDDING_MODEL,
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
const result = await selectRecalledFacts(snapshot(RETRIEVAL_K + 1), QUERY, {
|
|
270
|
+
...baseOptions(embed),
|
|
271
|
+
priorReport: prior,
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
expect(embed.calls).toHaveLength(0);
|
|
275
|
+
expect(result.content?.facts).toEqual(["fact 1", "fact 3"]);
|
|
276
|
+
expect(result.report).toBe(prior);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("replays a wholesale prior report as wholesale — a recorded embed failure is never retried into a different prompt", async () => {
|
|
280
|
+
const embed = angularEmbedder();
|
|
281
|
+
const prior = create(RecalledMemoriesReportSchema, { selectionActive: false });
|
|
282
|
+
|
|
283
|
+
const result = await selectRecalledFacts(snapshot(RETRIEVAL_K + 1), QUERY, {
|
|
284
|
+
...baseOptions(embed),
|
|
285
|
+
priorReport: prior,
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
expect(embed.calls).toHaveLength(0);
|
|
289
|
+
expect(result.content?.facts).toHaveLength(RETRIEVAL_K + 1);
|
|
290
|
+
expect(result.report?.selectionActive).toBe(false);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("re-selects when a recorded id does not resolve against the snapshot (structurally impossible, defended anyway)", async () => {
|
|
294
|
+
const embed = angularEmbedder();
|
|
295
|
+
const prior = create(RecalledMemoriesReportSchema, {
|
|
296
|
+
selectionActive: true,
|
|
297
|
+
injectedMemoryIds: ["mem_1", "mem_never_existed"],
|
|
298
|
+
embeddingModel: EMBEDDING_MODEL,
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
const result = await selectRecalledFacts(snapshot(RETRIEVAL_K + 1), QUERY, {
|
|
302
|
+
...baseOptions(embed),
|
|
303
|
+
priorReport: prior,
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
expect(embed.calls).toHaveLength(1);
|
|
307
|
+
expect(result.report?.selectionActive).toBe(true);
|
|
308
|
+
expect(result.report?.injectedMemoryIds).toHaveLength(RETRIEVAL_K);
|
|
309
|
+
});
|
|
310
|
+
});
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The runner-synthesized memory capture attachment (DD-005 D1).
|
|
3
|
+
*
|
|
4
|
+
* When the execution's recall snapshot says memory is on
|
|
5
|
+
* (`spec.recalled_memories.enabled` — the ONE server-owned field that
|
|
6
|
+
* serves both the recall and capture sides, stamped by the create
|
|
7
|
+
* pipeline's compose step), the runner synthesizes ONE MCP attachment
|
|
8
|
+
* serving the `remember` tool. No discovery RPC exists on this path:
|
|
9
|
+
* unlike the channel attachment's registry read, the enablement answer
|
|
10
|
+
* is already on the spec — a free, synchronous read (the
|
|
11
|
+
* conversation-attachment session-label precedent). The enabled bit with
|
|
12
|
+
* ZERO facts is a meaningful state: memory is on, nothing stored yet —
|
|
13
|
+
* the tool is offered so the first fact can be proposed.
|
|
14
|
+
*
|
|
15
|
+
* Two connection shapes, one roster (the channels pattern):
|
|
16
|
+
* - Bridge endpoint configured (cloud): Streamable HTTP against the
|
|
17
|
+
* bridge's /memory route with the execution's own session-scoped
|
|
18
|
+
* credential as the Bearer token, plus the capture context as
|
|
19
|
+
* per-request headers.
|
|
20
|
+
* - No bridge endpoint (OSS/local): a spawned `stigmer mcp-server`
|
|
21
|
+
* stdio child with STIGMER_MCP_ROSTER=memory, plus the capture
|
|
22
|
+
* context as STIGMER_MEMORY_* env.
|
|
23
|
+
*
|
|
24
|
+
* The capture context (org + agent/session/execution ids) is
|
|
25
|
+
* attribution, never authorization (the Stage 3 provenance decision,
|
|
26
|
+
* owner-ratified 2026-08-22): the cloud create handler accepts it only
|
|
27
|
+
* from a session-sandbox credential and overrides session/org with the
|
|
28
|
+
* token's own claims; the OSS server stores it under the local
|
|
29
|
+
* single-user trust model. The subject is never threaded — the server
|
|
30
|
+
* derives it from the credential (DD-005 D2).
|
|
31
|
+
*
|
|
32
|
+
* Approval-free by construction (the synthesized-attachment contract):
|
|
33
|
+
* the tool only ever creates a PROPOSAL the user must confirm through
|
|
34
|
+
* the control plane, so gating the propose call would stack a second
|
|
35
|
+
* consent gate in front of the real one (DD-005 D3: consent is the
|
|
36
|
+
* confirm RPC, not tool approval). Callers inject AFTER resolve +
|
|
37
|
+
* backfill.
|
|
38
|
+
*
|
|
39
|
+
* Failure posture: the attachment is synthesized from values already in
|
|
40
|
+
* hand, so the only failure mode is the create RPC refusing at call
|
|
41
|
+
* time — which the mcp-server's memory error mapper relays honestly.
|
|
42
|
+
* When the snapshot is absent or disabled: no tool, honest absence.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
import type { RecalledMemories } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/spec_pb";
|
|
46
|
+
import type { ResolvedMcpServer } from "./mcp-resolver.js";
|
|
47
|
+
import { grpcTarget, 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. The ROUTE
|
|
54
|
+
* and the context keys below are cross-repo strings, pinned on both
|
|
55
|
+
* sides (the TOOL_CALL_LIMIT precedent).
|
|
56
|
+
*/
|
|
57
|
+
export const MEMORY_ATTACHMENT_SLUG = "stigmer-memory";
|
|
58
|
+
|
|
59
|
+
/** The bridge route serving the memory-only roster (mcp-server twin: MEMORY_ROUTE). */
|
|
60
|
+
export const MEMORY_ROUTE = "/memory";
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The capture-context carriers, mirrored byte-for-byte by the
|
|
64
|
+
* mcp-server's memory domain (domains/memory/context.ts): headers on
|
|
65
|
+
* the bridge's per-request path, env on the stdio child — the same
|
|
66
|
+
* per-request-then-startup split the credential itself uses.
|
|
67
|
+
*/
|
|
68
|
+
export const MEMORY_ORG_HEADER = "x-stigmer-memory-org";
|
|
69
|
+
export const MEMORY_AGENT_ID_HEADER = "x-stigmer-memory-agent-id";
|
|
70
|
+
export const MEMORY_SESSION_ID_HEADER = "x-stigmer-memory-session-id";
|
|
71
|
+
export const MEMORY_EXECUTION_ID_HEADER = "x-stigmer-memory-execution-id";
|
|
72
|
+
|
|
73
|
+
export const MEMORY_ORG_ENV = "STIGMER_MEMORY_ORG";
|
|
74
|
+
export const MEMORY_AGENT_ID_ENV = "STIGMER_MEMORY_AGENT_ID";
|
|
75
|
+
export const MEMORY_SESSION_ID_ENV = "STIGMER_MEMORY_SESSION_ID";
|
|
76
|
+
export const MEMORY_EXECUTION_ID_ENV = "STIGMER_MEMORY_EXECUTION_ID";
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Where a proposed memory comes from — threaded to the mcp-server so
|
|
80
|
+
* the create request carries org addressing and provenance. Empty
|
|
81
|
+
* fields are omitted from the carrier (best-effort attribution; the
|
|
82
|
+
* server treats absent as empty).
|
|
83
|
+
*/
|
|
84
|
+
export interface MemoryCaptureContext {
|
|
85
|
+
readonly org: string;
|
|
86
|
+
readonly agentId: string;
|
|
87
|
+
readonly sessionId: string;
|
|
88
|
+
readonly agentExecutionId: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Reports whether the execution's recall snapshot offers the remember
|
|
93
|
+
* tool (DD-005 D1: the snapshot's enabled bit IS the runner's injection
|
|
94
|
+
* signal — one server-owned field, no parallel flag, no discovery
|
|
95
|
+
* round-trip). Exposed for the harnesses' MCP gates, which must open
|
|
96
|
+
* MCP resolution for a memory-only agent.
|
|
97
|
+
*/
|
|
98
|
+
export function memoryCaptureEnabled(recalled: RecalledMemories | undefined): boolean {
|
|
99
|
+
return recalled?.enabled === true;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Synthesize the memory capture attachment. Returns undefined when the
|
|
104
|
+
* recall snapshot is absent or disabled — the attachment exists exactly
|
|
105
|
+
* when the server-composed snapshot says memory is on.
|
|
106
|
+
*/
|
|
107
|
+
export function synthesizeMemoryAttachment(
|
|
108
|
+
recalled: RecalledMemories | undefined,
|
|
109
|
+
context: MemoryCaptureContext,
|
|
110
|
+
options: SynthesizedAttachmentOptions,
|
|
111
|
+
): ResolvedMcpServer | undefined {
|
|
112
|
+
if (!memoryCaptureEnabled(recalled)) {
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Approval-free by construction + backfill-proof: see file header.
|
|
117
|
+
const base = {
|
|
118
|
+
slug: MEMORY_ATTACHMENT_SLUG,
|
|
119
|
+
toolApprovals: [],
|
|
120
|
+
pinnedToolApprovals: [],
|
|
121
|
+
toolApprovalOverrides: [],
|
|
122
|
+
discoveredCapabilitiesEmpty: false,
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
if (options.bridgeEndpoint !== null && options.bridgeEndpoint !== "") {
|
|
126
|
+
return {
|
|
127
|
+
...base,
|
|
128
|
+
connectionType: "http",
|
|
129
|
+
url: options.bridgeEndpoint.replace(/\/+$/, "") + MEMORY_ROUTE,
|
|
130
|
+
headers: {
|
|
131
|
+
...(options.credential !== null && options.credential !== ""
|
|
132
|
+
? { Authorization: `Bearer ${options.credential}` }
|
|
133
|
+
: undefined),
|
|
134
|
+
...nonEmptyEntries([
|
|
135
|
+
[MEMORY_ORG_HEADER, context.org],
|
|
136
|
+
[MEMORY_AGENT_ID_HEADER, context.agentId],
|
|
137
|
+
[MEMORY_SESSION_ID_HEADER, context.sessionId],
|
|
138
|
+
[MEMORY_EXECUTION_ID_HEADER, context.agentExecutionId],
|
|
139
|
+
]),
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
...base,
|
|
146
|
+
connectionType: "stdio",
|
|
147
|
+
command: "stigmer",
|
|
148
|
+
args: ["mcp-server"],
|
|
149
|
+
env: {
|
|
150
|
+
STIGMER_MCP_ROSTER: "memory",
|
|
151
|
+
STIGMER_SERVER_ADDRESS: grpcTarget(options.backendEndpoint),
|
|
152
|
+
...nonEmptyEntries([
|
|
153
|
+
[MEMORY_ORG_ENV, context.org],
|
|
154
|
+
[MEMORY_AGENT_ID_ENV, context.agentId],
|
|
155
|
+
[MEMORY_SESSION_ID_ENV, context.sessionId],
|
|
156
|
+
[MEMORY_EXECUTION_ID_ENV, context.agentExecutionId],
|
|
157
|
+
]),
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The non-empty context fields as carrier entries (absent means empty). */
|
|
163
|
+
function nonEmptyEntries(
|
|
164
|
+
pairs: ReadonlyArray<readonly [string, string]>,
|
|
165
|
+
): Record<string, string> {
|
|
166
|
+
return Object.fromEntries(pairs.filter(([, value]) => value !== ""));
|
|
167
|
+
}
|