@effect-agent/testing 0.0.1-beta.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/index.d.mts +3331 -0
- package/dist/index.mjs +4205 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +47 -0
- package/src/certification.ts +979 -0
- package/src/chaos.ts +1187 -0
- package/src/fixtures/docs-researcher/definition.ts +342 -0
- package/src/fixtures/docs-researcher/harness.ts +316 -0
- package/src/fixtures/docs-researcher/index.ts +34 -0
- package/src/fixtures/docs-researcher/mcp.ts +126 -0
- package/src/fixtures/travel-planner/definition.ts +167 -0
- package/src/fixtures/travel-planner/deterministic-layers.ts +431 -0
- package/src/fixtures/travel-planner/index.ts +11 -0
- package/src/fixtures/travel-planner/phase2.ts +94 -0
- package/src/fixtures/travel-planner/phase3.ts +159 -0
- package/src/fixtures/travel-planner/phase4.ts +272 -0
- package/src/fixtures/travel-planner/phase5.ts +476 -0
- package/src/fixtures/travel-planner/phase6.ts +923 -0
- package/src/fixtures/travel-planner/phase7.ts +112 -0
- package/src/fixtures/travel-planner/scenarios.ts +85 -0
- package/src/fixtures/travel-planner/subagents-durable.ts +391 -0
- package/src/fixtures/travel-planner/subagents.ts +525 -0
- package/src/index.ts +5 -0
- package/src/scripted-model.ts +303 -0
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import { Subagent, SubagentPolicy, SubagentRuntime } from "@effect-agent/capabilities";
|
|
2
|
+
import { Agent, AgentPolicy } from "@effect-agent/core";
|
|
3
|
+
import type { RuntimeBinding } from "@effect-agent/engine";
|
|
4
|
+
import { Context, Effect, Schema } from "effect";
|
|
5
|
+
import { Tool, Toolkit } from "effect/unstable/ai";
|
|
6
|
+
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Docs Researcher (P7 internal agent #3, plan §6): a coordinator that
|
|
9
|
+
// delegates per-document summarization to a doc-summarizer child through the
|
|
10
|
+
// S2 durable delegation surface, with the child's content tools served —
|
|
11
|
+
// and validated — through the MCP connector against a scripted MCP fixture.
|
|
12
|
+
// The corpus, tools, and both Agent Definitions are deterministic fixtures in
|
|
13
|
+
// the travel-planner style so DN tests (and any later DC assembly) reuse them.
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
export const ResearchDocumentId = Schema.NonEmptyString.check(Schema.isMaxLength(64)).pipe(
|
|
17
|
+
Schema.brand("@effect-agent/testing/docs-researcher/ResearchDocumentId"),
|
|
18
|
+
);
|
|
19
|
+
export type ResearchDocumentId = typeof ResearchDocumentId.Type;
|
|
20
|
+
|
|
21
|
+
const BoundedTitle = Schema.NonEmptyString.check(Schema.isMaxLength(120));
|
|
22
|
+
const BoundedBody = Schema.NonEmptyString.check(Schema.isMaxLength(16 * 1024));
|
|
23
|
+
/** Bounded summary text: the ONLY child-derived text that may cross to the parent. */
|
|
24
|
+
export const BoundedSummary = Schema.NonEmptyString.check(Schema.isMaxLength(240));
|
|
25
|
+
|
|
26
|
+
export class DocumentQuery extends Schema.Class<DocumentQuery>("DocumentQuery")({
|
|
27
|
+
documentId: ResearchDocumentId,
|
|
28
|
+
}) {}
|
|
29
|
+
|
|
30
|
+
/** One bounded research document as the MCP content server exposes it. */
|
|
31
|
+
export class ResearchDocument extends Schema.Class<ResearchDocument>("ResearchDocument")({
|
|
32
|
+
documentId: ResearchDocumentId,
|
|
33
|
+
title: BoundedTitle,
|
|
34
|
+
body: BoundedBody,
|
|
35
|
+
}) {}
|
|
36
|
+
|
|
37
|
+
export class DocumentUnavailable extends Schema.TaggedErrorClass<DocumentUnavailable>()(
|
|
38
|
+
"DocumentUnavailable",
|
|
39
|
+
{
|
|
40
|
+
documentId: ResearchDocumentId,
|
|
41
|
+
message: Schema.String,
|
|
42
|
+
},
|
|
43
|
+
) {}
|
|
44
|
+
|
|
45
|
+
/** The content store behind the scripted MCP server. */
|
|
46
|
+
export class DocumentLibrary extends Context.Service<
|
|
47
|
+
DocumentLibrary,
|
|
48
|
+
{
|
|
49
|
+
readonly fetch: (query: DocumentQuery) => Effect.Effect<ResearchDocument, DocumentUnavailable>;
|
|
50
|
+
}
|
|
51
|
+
>()("@effect-agent/testing/docs-researcher/DocumentLibrary") {}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The one content tool the doc-summarizer child uses. Its authored JSON
|
|
55
|
+
* schema is what MCP discovery must serve byte-for-byte: the scripted MCP
|
|
56
|
+
* fixture derives its discovery entry from `Tool.getJsonSchema(FetchDocument)`
|
|
57
|
+
* and `validateMcpDiscovery` re-derives and digests both sides (CAP-009).
|
|
58
|
+
*/
|
|
59
|
+
export const FetchDocument = Tool.make("fetch_document", {
|
|
60
|
+
description: "Fetch one bounded research document by its identifier.",
|
|
61
|
+
parameters: DocumentQuery,
|
|
62
|
+
success: ResearchDocument,
|
|
63
|
+
failure: DocumentUnavailable,
|
|
64
|
+
failureMode: "error",
|
|
65
|
+
dependencies: [DocumentLibrary],
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
export const DocContentToolkit = Toolkit.make(FetchDocument);
|
|
69
|
+
export const docContentToolkitLayer = DocContentToolkit.toLayer({
|
|
70
|
+
fetch_document: (query) => Effect.flatMap(DocumentLibrary, (library) => library.fetch(query)),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// Deterministic corpus. Every body deliberately embeds BOTH a secret marker
|
|
75
|
+
// and a distinctive body phrase: the tests assert that neither ever reaches
|
|
76
|
+
// the parent Conversation, the parent prompts, or a redacted preview — only
|
|
77
|
+
// the bounded summary crosses the delegation boundary (SUB-015, SEC-008).
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
/** Never allowed outside a child Conversation or an unredacted fixture value. */
|
|
81
|
+
export const docsDocumentBodySecret = "docs-vault-secret-771";
|
|
82
|
+
|
|
83
|
+
const decodeDocumentId = Schema.decodeSync(ResearchDocumentId);
|
|
84
|
+
|
|
85
|
+
interface CorpusEntry {
|
|
86
|
+
readonly document: ResearchDocument;
|
|
87
|
+
readonly bodyPhrase: string;
|
|
88
|
+
readonly summary: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const corpusEntries = new Map<string, CorpusEntry>(
|
|
92
|
+
[
|
|
93
|
+
{
|
|
94
|
+
documentId: "durability-notes",
|
|
95
|
+
title: "Durability protocol notes",
|
|
96
|
+
bodyPhrase: "amber-ledger-passage",
|
|
97
|
+
summary:
|
|
98
|
+
"Settlement results are recorded exactly once while external side effects stay at-least-once.",
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
documentId: "subagent-notes",
|
|
102
|
+
title: "Subagent join notes",
|
|
103
|
+
bodyPhrase: "cobalt-join-corridor",
|
|
104
|
+
summary: "A parent joins only the verified settlement of its own established child.",
|
|
105
|
+
},
|
|
106
|
+
].map((entry) => [
|
|
107
|
+
entry.documentId,
|
|
108
|
+
{
|
|
109
|
+
document: ResearchDocument.make({
|
|
110
|
+
documentId: decodeDocumentId(entry.documentId),
|
|
111
|
+
title: entry.title,
|
|
112
|
+
body: `${entry.bodyPhrase}: internal working notes. ${docsDocumentBodySecret}. ${entry.summary} Raw notes stay inside the child Conversation.`,
|
|
113
|
+
}),
|
|
114
|
+
bodyPhrase: entry.bodyPhrase,
|
|
115
|
+
summary: entry.summary,
|
|
116
|
+
},
|
|
117
|
+
]),
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
/** The corpus document ids in canonical fixture order. */
|
|
121
|
+
export const researchCorpusDocumentIds: ReadonlyArray<ResearchDocumentId> = [
|
|
122
|
+
decodeDocumentId("durability-notes"),
|
|
123
|
+
decodeDocumentId("subagent-notes"),
|
|
124
|
+
];
|
|
125
|
+
|
|
126
|
+
const requireCorpusEntry = (documentId: string): CorpusEntry => {
|
|
127
|
+
const entry = corpusEntries.get(documentId);
|
|
128
|
+
if (entry === undefined) {
|
|
129
|
+
throw new Error(`No deterministic corpus entry exists for document ${documentId}`);
|
|
130
|
+
}
|
|
131
|
+
return entry;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/** Deterministic library lookup shared by the scripted MCP content handlers. */
|
|
135
|
+
export const researchDocumentLookup = (
|
|
136
|
+
query: DocumentQuery,
|
|
137
|
+
): Effect.Effect<ResearchDocument, DocumentUnavailable> => {
|
|
138
|
+
const entry = corpusEntries.get(query.documentId);
|
|
139
|
+
return entry === undefined
|
|
140
|
+
? Effect.fail(
|
|
141
|
+
DocumentUnavailable.make({
|
|
142
|
+
documentId: query.documentId,
|
|
143
|
+
message: "No deterministic corpus entry exists for this document.",
|
|
144
|
+
}),
|
|
145
|
+
)
|
|
146
|
+
: Effect.succeed(entry.document);
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
/** The full fixture document (body includes the secret marker — child-side only). */
|
|
150
|
+
export const researchDocumentFor = (documentId: string): ResearchDocument =>
|
|
151
|
+
requireCorpusEntry(documentId).document;
|
|
152
|
+
|
|
153
|
+
/** The distinctive body phrase used by context-isolation assertions. */
|
|
154
|
+
export const documentBodyPhrase = (documentId: string): string =>
|
|
155
|
+
requireCorpusEntry(documentId).bodyPhrase;
|
|
156
|
+
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
// Doc Summarizer: the child Agent Definition. Its toolkit is the authored
|
|
159
|
+
// `DocContentToolkit`; the harness registers its worker Binding only after
|
|
160
|
+
// MCP discovery validates that exact toolkit (mcp.ts).
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
export class SummaryBrief extends Schema.Class<SummaryBrief>("SummaryBrief")({
|
|
164
|
+
documentId: ResearchDocumentId,
|
|
165
|
+
focus: Schema.NonEmptyString,
|
|
166
|
+
}) {}
|
|
167
|
+
|
|
168
|
+
export class DocumentSummary extends Schema.Class<DocumentSummary>("DocumentSummary")({
|
|
169
|
+
documentId: ResearchDocumentId,
|
|
170
|
+
summary: BoundedSummary,
|
|
171
|
+
}) {}
|
|
172
|
+
|
|
173
|
+
/** The summary the scripted child writes after fetching the document. */
|
|
174
|
+
export const documentSummaryFor = (documentId: string): DocumentSummary =>
|
|
175
|
+
DocumentSummary.make({
|
|
176
|
+
documentId: requireCorpusEntry(documentId).document.documentId,
|
|
177
|
+
summary: requireCorpusEntry(documentId).summary,
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
export const encodedDocumentSummary = (documentId: string): string =>
|
|
181
|
+
JSON.stringify(Schema.encodeSync(DocumentSummary)(documentSummaryFor(documentId)));
|
|
182
|
+
|
|
183
|
+
export const DocSummarizer = Agent.define("doc-summarizer", {
|
|
184
|
+
input: SummaryBrief,
|
|
185
|
+
output: DocumentSummary,
|
|
186
|
+
instructions:
|
|
187
|
+
"Fetch the briefed document with fetch_document exactly once, then return only a JSON document summary. Never copy raw notes or secrets into the summary.",
|
|
188
|
+
toolkit: DocContentToolkit,
|
|
189
|
+
policy: AgentPolicy.make({
|
|
190
|
+
maxTurns: 2,
|
|
191
|
+
maxToolCalls: 1,
|
|
192
|
+
maxDuration: "30 seconds",
|
|
193
|
+
toolConcurrency: 1,
|
|
194
|
+
}),
|
|
195
|
+
description: "Summarize one bounded research document fetched through MCP content tools.",
|
|
196
|
+
metadata: { deploymentClass: "DN", phase: "P7" },
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// ---------------------------------------------------------------------------
|
|
200
|
+
// Delegation Definition: the coordinator sees exactly one Tool with explicit
|
|
201
|
+
// projections and finite bounds. `projectResult` is the declassification
|
|
202
|
+
// boundary (SUB-015): only the bounded summary crosses; the fetched body —
|
|
203
|
+
// secret marker included — stays in the child Conversation.
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
export class SummaryRequest extends Schema.Class<SummaryRequest>("SummaryRequest")({
|
|
207
|
+
documentId: ResearchDocumentId,
|
|
208
|
+
}) {}
|
|
209
|
+
|
|
210
|
+
export class SummaryFinding extends Schema.Class<SummaryFinding>("SummaryFinding")({
|
|
211
|
+
documentId: ResearchDocumentId,
|
|
212
|
+
summary: BoundedSummary,
|
|
213
|
+
}) {}
|
|
214
|
+
|
|
215
|
+
export class DocumentSummaryFailed extends Schema.TaggedErrorClass<DocumentSummaryFailed>()(
|
|
216
|
+
"DocumentSummaryFailed",
|
|
217
|
+
{
|
|
218
|
+
childErrorTag: Schema.NonEmptyString,
|
|
219
|
+
},
|
|
220
|
+
) {}
|
|
221
|
+
|
|
222
|
+
/** Finite per-invocation bounds (SUB-009): one fetch per child, two children per Run. */
|
|
223
|
+
export const documentSummaryPolicy = SubagentPolicy.make({
|
|
224
|
+
maxChildren: 2,
|
|
225
|
+
maxConcurrency: 2,
|
|
226
|
+
maxTurns: 2,
|
|
227
|
+
maxToolCalls: 1,
|
|
228
|
+
maxDuration: "10 seconds",
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
export const delegateDocumentSummary = Subagent.define("delegate_document_summary", {
|
|
232
|
+
description:
|
|
233
|
+
"Summarize one research document through the doc-summarizer child and return a bounded finding.",
|
|
234
|
+
target: DocSummarizer,
|
|
235
|
+
parameters: SummaryRequest,
|
|
236
|
+
success: SummaryFinding,
|
|
237
|
+
failure: DocumentSummaryFailed,
|
|
238
|
+
prepareInput: (request) =>
|
|
239
|
+
Effect.succeed(
|
|
240
|
+
SummaryBrief.make({
|
|
241
|
+
documentId: request.documentId,
|
|
242
|
+
focus: "summarize:durability-claims",
|
|
243
|
+
}),
|
|
244
|
+
),
|
|
245
|
+
projectResult: (summary) =>
|
|
246
|
+
Effect.succeed(
|
|
247
|
+
SummaryFinding.make({
|
|
248
|
+
documentId: summary.documentId,
|
|
249
|
+
summary: summary.summary,
|
|
250
|
+
}),
|
|
251
|
+
),
|
|
252
|
+
policy: documentSummaryPolicy,
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
/** Total mapping from every expected child Run failure to the declared Tool failure (SUB-028). */
|
|
256
|
+
export const mapSummaryChildFailure = (failure: { readonly _tag: string }): DocumentSummaryFailed =>
|
|
257
|
+
DocumentSummaryFailed.make({ childErrorTag: failure._tag });
|
|
258
|
+
|
|
259
|
+
/** The exact digest strings the durable declaration AND host registration must share (SUB-023). */
|
|
260
|
+
export const docsSummarizerDigestStrings = {
|
|
261
|
+
agent: "50".repeat(32),
|
|
262
|
+
model: "51".repeat(32),
|
|
263
|
+
tools: "52".repeat(32),
|
|
264
|
+
} as const;
|
|
265
|
+
|
|
266
|
+
/** Runtime wiring: the immutable delegation plus one explicit child Binding (S2 declaration). */
|
|
267
|
+
export const docsSummaryHandlersLayer = <Provider, ModelProvides, ModelRequires>(
|
|
268
|
+
childBinding: RuntimeBinding<
|
|
269
|
+
typeof SummaryBrief,
|
|
270
|
+
typeof DocumentSummary,
|
|
271
|
+
string,
|
|
272
|
+
Toolkit.Tools<typeof DocContentToolkit>,
|
|
273
|
+
Provider,
|
|
274
|
+
ModelProvides,
|
|
275
|
+
ModelRequires
|
|
276
|
+
>,
|
|
277
|
+
) =>
|
|
278
|
+
SubagentRuntime.layer(delegateDocumentSummary, childBinding, {
|
|
279
|
+
mapChildFailure: mapSummaryChildFailure,
|
|
280
|
+
durable: { targetDigests: docsSummarizerDigestStrings },
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// ---------------------------------------------------------------------------
|
|
284
|
+
// Docs Researcher: the parent Agent Definition.
|
|
285
|
+
// ---------------------------------------------------------------------------
|
|
286
|
+
|
|
287
|
+
export class ResearchRequest extends Schema.Class<ResearchRequest>("ResearchRequest")({
|
|
288
|
+
question: Schema.NonEmptyString,
|
|
289
|
+
documentIds: Schema.Array(ResearchDocumentId).check(Schema.isMinLength(1)),
|
|
290
|
+
}) {}
|
|
291
|
+
|
|
292
|
+
export class ResearchDigest extends Schema.Class<ResearchDigest>("ResearchDigest")({
|
|
293
|
+
findings: Schema.Array(SummaryFinding),
|
|
294
|
+
nextAction: Schema.Literal("review"),
|
|
295
|
+
}) {}
|
|
296
|
+
|
|
297
|
+
/** Parent-only transcript markers proving child context isolation (SUB-006/SUB-015). */
|
|
298
|
+
export const docsCoordinatorConfidentialMarker = "docs-coordinator-vault-19x";
|
|
299
|
+
export const docsMissionConfidentialMarker = "docs-mission-dossier-42f";
|
|
300
|
+
|
|
301
|
+
export const DocsResearcherToolkit = Toolkit.make(delegateDocumentSummary.tool);
|
|
302
|
+
|
|
303
|
+
export const DocsResearcher = Agent.define("docs-researcher", {
|
|
304
|
+
input: ResearchRequest,
|
|
305
|
+
output: ResearchDigest,
|
|
306
|
+
instructions: [
|
|
307
|
+
"You are the Effect Agent P7 docs-researcher coordinator.",
|
|
308
|
+
`Coordinator-only context: ${docsCoordinatorConfidentialMarker}.`,
|
|
309
|
+
"Call delegate_document_summary once per requested document in one Tool batch.",
|
|
310
|
+
"Return only a JSON digest built from the delegated findings. This is read-only research.",
|
|
311
|
+
].join("\n"),
|
|
312
|
+
toolkit: DocsResearcherToolkit,
|
|
313
|
+
policy: AgentPolicy.make({
|
|
314
|
+
maxTurns: 2,
|
|
315
|
+
maxToolCalls: 2,
|
|
316
|
+
maxDuration: "30 seconds",
|
|
317
|
+
toolConcurrency: 2,
|
|
318
|
+
}),
|
|
319
|
+
description: "Coordinate per-document summarization through one declared delegation Tool.",
|
|
320
|
+
metadata: { deploymentClass: "DN", phase: "P7" },
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
/** The default two-document research mission. */
|
|
324
|
+
export const researchMissionRequest = ResearchRequest.make({
|
|
325
|
+
question: `Summarize the durability and subagent notes; keep ${docsMissionConfidentialMarker} inside the coordinator conversation.`,
|
|
326
|
+
documentIds: researchCorpusDocumentIds,
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
/** The coordinator's expected final digest for the given documents. */
|
|
330
|
+
export const expectedResearchDigest = (
|
|
331
|
+
documentIds: ReadonlyArray<string> = researchCorpusDocumentIds,
|
|
332
|
+
): ResearchDigest =>
|
|
333
|
+
ResearchDigest.make({
|
|
334
|
+
findings: documentIds.map((documentId) => {
|
|
335
|
+
const summary = documentSummaryFor(documentId);
|
|
336
|
+
return SummaryFinding.make({
|
|
337
|
+
documentId: summary.documentId,
|
|
338
|
+
summary: summary.summary,
|
|
339
|
+
});
|
|
340
|
+
}),
|
|
341
|
+
nextAction: "review",
|
|
342
|
+
});
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import {
|
|
2
|
+
connectMcp,
|
|
3
|
+
Redactor,
|
|
4
|
+
SubagentReservationsMemoryLive,
|
|
5
|
+
type McpDiscovery,
|
|
6
|
+
type RedactedPreview,
|
|
7
|
+
type RedactionError,
|
|
8
|
+
} from "@effect-agent/capabilities";
|
|
9
|
+
import { Agent, type ConversationId } from "@effect-agent/core";
|
|
10
|
+
import {
|
|
11
|
+
DefinitionDigests,
|
|
12
|
+
DeploymentId,
|
|
13
|
+
Digest,
|
|
14
|
+
DurableWorkerBinding,
|
|
15
|
+
Principal,
|
|
16
|
+
ProducerId,
|
|
17
|
+
type DurableSubmitOptions,
|
|
18
|
+
type IdempotencyKey,
|
|
19
|
+
type ResolvedBinding,
|
|
20
|
+
} from "@effect-agent/session";
|
|
21
|
+
import { Crypto, Effect, Layer, Ref, Schema, Stream } from "effect";
|
|
22
|
+
import { LanguageModel, Model, type Response } from "effect/unstable/ai";
|
|
23
|
+
|
|
24
|
+
import { DeterministicIdGeneratorLayer } from "../travel-planner/deterministic-layers.ts";
|
|
25
|
+
import {
|
|
26
|
+
DocsResearcher,
|
|
27
|
+
DocSummarizer,
|
|
28
|
+
DocumentLibrary,
|
|
29
|
+
docContentToolkitLayer,
|
|
30
|
+
docsSummaryHandlersLayer,
|
|
31
|
+
encodedDocumentSummary,
|
|
32
|
+
expectedResearchDigest,
|
|
33
|
+
ResearchDigest,
|
|
34
|
+
ResearchDocument,
|
|
35
|
+
researchCorpusDocumentIds,
|
|
36
|
+
researchDocumentFor,
|
|
37
|
+
researchDocumentLookup,
|
|
38
|
+
} from "./definition.ts";
|
|
39
|
+
import {
|
|
40
|
+
assertDiscoveryMatchesAuthoredToolkit,
|
|
41
|
+
docsMcpConnectorLayer,
|
|
42
|
+
docsMcpRequest,
|
|
43
|
+
} from "./mcp.ts";
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// DN durable harness for the docs-researcher (P7 plan §6 agent #3), following
|
|
47
|
+
// `makeDurableResearchHarness` conventions: invocation counters and captured
|
|
48
|
+
// prompts live OUTSIDE the Model Layers so they survive Layer rebuilds across
|
|
49
|
+
// Attempts and separate runtime handles over the same SQLite file.
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
export const docsResearcherDeploymentId = Schema.decodeSync(DeploymentId)(
|
|
53
|
+
"docs-researcher-p7-deployment",
|
|
54
|
+
);
|
|
55
|
+
export const docsResearcherProducerId = Schema.decodeSync(ProducerId)(
|
|
56
|
+
"docs-researcher-p7-producer",
|
|
57
|
+
);
|
|
58
|
+
export const docsResearcherPrincipal = Schema.decodeSync(Principal)("docs-researcher-p7-principal");
|
|
59
|
+
|
|
60
|
+
const digestOf = (pair: string) => Schema.decodeSync(Digest)(pair.repeat(32));
|
|
61
|
+
|
|
62
|
+
/** Redacted, deterministic coordinator definition digests for this fixture version. */
|
|
63
|
+
export const docsCoordinatorDigests = DefinitionDigests.make({
|
|
64
|
+
agent: digestOf("40"),
|
|
65
|
+
model: digestOf("41"),
|
|
66
|
+
tools: digestOf("42"),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
/** The child registration digests — byte-equal to `docsSummarizerDigestStrings` (SUB-023). */
|
|
70
|
+
export const docsSummarizerDigests = DefinitionDigests.make({
|
|
71
|
+
agent: digestOf("50"),
|
|
72
|
+
model: digestOf("51"),
|
|
73
|
+
tools: digestOf("52"),
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
/** Durable admission options for one docs-researcher Submission on one mission lane. */
|
|
77
|
+
export const docsResearcherSubmitOptions = (
|
|
78
|
+
conversationId: ConversationId,
|
|
79
|
+
idempotencyKey: IdempotencyKey,
|
|
80
|
+
): DurableSubmitOptions => ({
|
|
81
|
+
conversationId,
|
|
82
|
+
principal: docsResearcherPrincipal,
|
|
83
|
+
idempotencyKey,
|
|
84
|
+
definitions: docsCoordinatorDigests,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
/** The structural submit slice of the coordinator Binding (`DurableAgentRuntime.submit`). */
|
|
88
|
+
export const docsResearcherSubmitAgent = {
|
|
89
|
+
definition: { id: DocsResearcher.id, input: DocsResearcher.input },
|
|
90
|
+
} as const;
|
|
91
|
+
|
|
92
|
+
/** The deterministic delegation Tool Call identity for one document. */
|
|
93
|
+
export const summarizeCallId = (documentId: string): string => `summarize-${documentId}`;
|
|
94
|
+
|
|
95
|
+
/** The child's own scripted fetch Tool Call identity for one document. */
|
|
96
|
+
export const fetchCallId = (documentId: string): string => `fetch-${documentId}`;
|
|
97
|
+
|
|
98
|
+
const scriptedUsage = { inputTokens: { total: 96 }, outputTokens: { total: 64 } };
|
|
99
|
+
|
|
100
|
+
const summaryDelegationParts = (
|
|
101
|
+
documentIds: ReadonlyArray<string>,
|
|
102
|
+
): ReadonlyArray<Response.StreamPartEncoded> => [
|
|
103
|
+
...documentIds.map(
|
|
104
|
+
(documentId): Response.StreamPartEncoded => ({
|
|
105
|
+
type: "tool-call",
|
|
106
|
+
id: summarizeCallId(documentId),
|
|
107
|
+
name: "delegate_document_summary",
|
|
108
|
+
params: { documentId },
|
|
109
|
+
providerExecuted: false,
|
|
110
|
+
}),
|
|
111
|
+
),
|
|
112
|
+
{ type: "finish", reason: "tool-calls", usage: scriptedUsage },
|
|
113
|
+
];
|
|
114
|
+
|
|
115
|
+
const digestParts = (
|
|
116
|
+
documentIds: ReadonlyArray<string>,
|
|
117
|
+
): ReadonlyArray<Response.StreamPartEncoded> => [
|
|
118
|
+
{ type: "text-start", id: "digest" },
|
|
119
|
+
{
|
|
120
|
+
type: "text-delta",
|
|
121
|
+
id: "digest",
|
|
122
|
+
delta: JSON.stringify(Schema.encodeSync(ResearchDigest)(expectedResearchDigest(documentIds))),
|
|
123
|
+
},
|
|
124
|
+
{ type: "text-end", id: "digest" },
|
|
125
|
+
{ type: "finish", reason: "stop", usage: scriptedUsage },
|
|
126
|
+
];
|
|
127
|
+
|
|
128
|
+
const fetchParts = (documentId: string): ReadonlyArray<Response.StreamPartEncoded> => [
|
|
129
|
+
{
|
|
130
|
+
type: "tool-call",
|
|
131
|
+
id: fetchCallId(documentId),
|
|
132
|
+
name: "fetch_document",
|
|
133
|
+
params: { documentId },
|
|
134
|
+
providerExecuted: false,
|
|
135
|
+
},
|
|
136
|
+
{ type: "finish", reason: "tool-calls", usage: scriptedUsage },
|
|
137
|
+
];
|
|
138
|
+
|
|
139
|
+
const summaryParts = (documentId: string): ReadonlyArray<Response.StreamPartEncoded> => [
|
|
140
|
+
{ type: "text-start", id: "document-summary" },
|
|
141
|
+
{ type: "text-delta", id: "document-summary", delta: encodedDocumentSummary(documentId) },
|
|
142
|
+
{ type: "text-end", id: "document-summary" },
|
|
143
|
+
{ type: "finish", reason: "stop", usage: scriptedUsage },
|
|
144
|
+
];
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* One prompt-aware scripted model with externally observable counters. A DN
|
|
148
|
+
* Attempt may resume on a fresh Layer build, so responses derive from the
|
|
149
|
+
* committed history in the prompt — never from an in-Layer turn counter.
|
|
150
|
+
*/
|
|
151
|
+
const makeCountingModel = (
|
|
152
|
+
name: string,
|
|
153
|
+
decide: (promptJson: string) => Effect.Effect<ReadonlyArray<Response.StreamPartEncoded>>,
|
|
154
|
+
) =>
|
|
155
|
+
Effect.gen(function* () {
|
|
156
|
+
const calls = yield* Ref.make(0);
|
|
157
|
+
const prompts = yield* Ref.make<ReadonlyArray<string>>([]);
|
|
158
|
+
const model = Model.make(
|
|
159
|
+
"scripted",
|
|
160
|
+
name,
|
|
161
|
+
Layer.effect(
|
|
162
|
+
LanguageModel.LanguageModel,
|
|
163
|
+
LanguageModel.make({
|
|
164
|
+
generateText: () => Effect.succeed([]),
|
|
165
|
+
streamText: (request) =>
|
|
166
|
+
Stream.unwrap(
|
|
167
|
+
Effect.gen(function* () {
|
|
168
|
+
yield* Ref.update(calls, (value) => value + 1);
|
|
169
|
+
const promptJson = JSON.stringify(request.prompt);
|
|
170
|
+
yield* Ref.update(prompts, (previous) => [...previous, promptJson]);
|
|
171
|
+
return Stream.fromIterable(yield* decide(promptJson));
|
|
172
|
+
}),
|
|
173
|
+
),
|
|
174
|
+
}),
|
|
175
|
+
),
|
|
176
|
+
);
|
|
177
|
+
return { model, calls: Ref.get(calls), prompts: Ref.get(prompts) };
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
/** Optional overrides for one docs-researcher harness. */
|
|
181
|
+
export interface DocsResearcherHarnessOptions {
|
|
182
|
+
/** Documents to research; defaults to the full two-document corpus. */
|
|
183
|
+
readonly documentIds?: ReadonlyArray<string> | undefined;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** One durable coordinator/summarizer pair with observable counters and MCP evidence. */
|
|
187
|
+
export interface DocsResearcherHarness {
|
|
188
|
+
/** Host registrations for `NodeDurableRuntimeOptions.bindings` (parent + child). */
|
|
189
|
+
readonly bindings: ReadonlyArray<ResolvedBinding>;
|
|
190
|
+
/** The validated MCP discovery the child toolkit registration was gated on. */
|
|
191
|
+
readonly discovery: McpDiscovery;
|
|
192
|
+
/** Total coordinator model invocations across every Attempt and runtime handle. */
|
|
193
|
+
readonly parentModelCalls: Effect.Effect<number>;
|
|
194
|
+
/** JSON-encoded coordinator prompts in request order. */
|
|
195
|
+
readonly parentPrompts: Effect.Effect<ReadonlyArray<string>>;
|
|
196
|
+
/** Total summarizer model invocations across every Attempt and runtime handle. */
|
|
197
|
+
readonly childModelCalls: Effect.Effect<number>;
|
|
198
|
+
/** JSON-encoded summarizer prompts in request order (context-isolation evidence). */
|
|
199
|
+
readonly childPrompts: Effect.Effect<ReadonlyArray<string>>;
|
|
200
|
+
/** MCP content-tool handler executions for one document (external side-effect record). */
|
|
201
|
+
readonly fetchInvocations: (documentId: string) => Effect.Effect<number>;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Build the docs-researcher harness. Order matters and is the point: the
|
|
206
|
+
* child's content toolkit is only registered as a worker Binding AFTER the
|
|
207
|
+
* MCP connector's bounded discovery validated the authored toolkit
|
|
208
|
+
* byte-for-byte (`connectMcp` + `assertDiscoveryMatchesAuthoredToolkit`), so
|
|
209
|
+
* "the tools the summarizer runs are the tools discovery served" is enforced
|
|
210
|
+
* at assembly, not assumed. Content-tool execution then flows through the
|
|
211
|
+
* counting `DocumentLibrary` — the scripted MCP server's content store.
|
|
212
|
+
*/
|
|
213
|
+
export const makeDocsResearcherHarness = (options?: DocsResearcherHarnessOptions) =>
|
|
214
|
+
Effect.gen(function* () {
|
|
215
|
+
const documentIds = options?.documentIds ?? researchCorpusDocumentIds;
|
|
216
|
+
|
|
217
|
+
// MCP discovery gate (CAP-009): bounded, digest-verified, fail-closed.
|
|
218
|
+
const discovery = yield* Effect.scoped(
|
|
219
|
+
Effect.gen(function* () {
|
|
220
|
+
const connection = yield* connectMcp(docsMcpRequest);
|
|
221
|
+
yield* assertDiscoveryMatchesAuthoredToolkit(connection);
|
|
222
|
+
return connection.discovery;
|
|
223
|
+
}),
|
|
224
|
+
).pipe(Effect.provide(docsMcpConnectorLayer));
|
|
225
|
+
|
|
226
|
+
const fetchCounts = yield* Ref.make<ReadonlyMap<string, number>>(new Map());
|
|
227
|
+
const libraryLayer = Layer.succeed(
|
|
228
|
+
DocumentLibrary,
|
|
229
|
+
DocumentLibrary.of({
|
|
230
|
+
fetch: (query) =>
|
|
231
|
+
Ref.update(fetchCounts, (current) =>
|
|
232
|
+
new Map(current).set(query.documentId, (current.get(query.documentId) ?? 0) + 1),
|
|
233
|
+
).pipe(Effect.andThen(researchDocumentLookup(query))),
|
|
234
|
+
}),
|
|
235
|
+
);
|
|
236
|
+
const childToolkitLayer = docContentToolkitLayer.pipe(Layer.provideMerge(libraryLayer));
|
|
237
|
+
|
|
238
|
+
const childModel = yield* makeCountingModel("doc-summarizer-p7", (promptJson) =>
|
|
239
|
+
Effect.suspend(() => {
|
|
240
|
+
const documentId = documentIds.find((candidate) => promptJson.includes(candidate));
|
|
241
|
+
if (documentId === undefined) {
|
|
242
|
+
return Effect.die(new Error("The summarizer prompt names no corpus document"));
|
|
243
|
+
}
|
|
244
|
+
return Effect.succeed(
|
|
245
|
+
promptJson.includes(fetchCallId(documentId))
|
|
246
|
+
? summaryParts(documentId)
|
|
247
|
+
: fetchParts(documentId),
|
|
248
|
+
);
|
|
249
|
+
}),
|
|
250
|
+
);
|
|
251
|
+
const childBinding = Agent.withModel(DocSummarizer, childModel.model);
|
|
252
|
+
|
|
253
|
+
const firstCallId = summarizeCallId(documentIds[0] ?? "durability-notes");
|
|
254
|
+
const parentModel = yield* makeCountingModel("docs-researcher-p7", (promptJson) =>
|
|
255
|
+
Effect.succeed(
|
|
256
|
+
promptJson.includes(firstCallId)
|
|
257
|
+
? digestParts(documentIds)
|
|
258
|
+
: summaryDelegationParts(documentIds),
|
|
259
|
+
),
|
|
260
|
+
);
|
|
261
|
+
const parentBinding = Agent.withModel(DocsResearcher, parentModel.model);
|
|
262
|
+
|
|
263
|
+
const delegationLayer = docsSummaryHandlersLayer(childBinding).pipe(
|
|
264
|
+
Layer.provide(
|
|
265
|
+
Layer.mergeAll(
|
|
266
|
+
childToolkitLayer,
|
|
267
|
+
SubagentReservationsMemoryLive,
|
|
268
|
+
DeterministicIdGeneratorLayer,
|
|
269
|
+
),
|
|
270
|
+
),
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
const parentResolved: ResolvedBinding = yield* DurableWorkerBinding.make(
|
|
274
|
+
parentBinding,
|
|
275
|
+
docsCoordinatorDigests,
|
|
276
|
+
).pipe(Effect.provide(delegationLayer));
|
|
277
|
+
const childResolved: ResolvedBinding = yield* DurableWorkerBinding.make(
|
|
278
|
+
childBinding,
|
|
279
|
+
docsSummarizerDigests,
|
|
280
|
+
).pipe(Effect.provide(childToolkitLayer));
|
|
281
|
+
|
|
282
|
+
const harness: DocsResearcherHarness = {
|
|
283
|
+
bindings: [parentResolved, childResolved],
|
|
284
|
+
discovery,
|
|
285
|
+
parentModelCalls: parentModel.calls,
|
|
286
|
+
parentPrompts: parentModel.prompts,
|
|
287
|
+
childModelCalls: childModel.calls,
|
|
288
|
+
childPrompts: childModel.prompts,
|
|
289
|
+
fetchInvocations: (documentId) =>
|
|
290
|
+
Ref.get(fetchCounts).pipe(Effect.map((current) => current.get(documentId) ?? 0)),
|
|
291
|
+
};
|
|
292
|
+
return harness;
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
const encodeResearchDocument = Schema.encodeEffect(ResearchDocument);
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* The audit-surface preview of one fetched document: the raw document —
|
|
299
|
+
* secret marker and all — passes through the configured structural `Redactor`
|
|
300
|
+
* before anything may quote it outside the child Conversation (SEC-008,
|
|
301
|
+
* CAP-013). Tests assert the preview keeps shape but no scalar content.
|
|
302
|
+
*/
|
|
303
|
+
export const redactedDocumentPreview = Effect.fn("DocsResearcher.redactedDocumentPreview")(
|
|
304
|
+
function* (documentId: string): Effect.fn.Return<RedactedPreview, RedactionError, Redactor> {
|
|
305
|
+
const redactor = yield* Redactor;
|
|
306
|
+
const encoded = yield* encodeResearchDocument(researchDocumentFor(documentId)).pipe(
|
|
307
|
+
Effect.orDie,
|
|
308
|
+
);
|
|
309
|
+
return yield* redactor.redact(encoded);
|
|
310
|
+
},
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
// Crypto is deliberately in the harness requirements (`connectMcp` digests
|
|
314
|
+
// discovery): callers provide a platform Crypto Layer, keeping this fixture
|
|
315
|
+
// platform-neutral.
|
|
316
|
+
export type DocsResearcherHarnessRequirements = Crypto.Crypto;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Docs Researcher fixture (P7 internal agent #3): S2 durable delegation on DN
|
|
3
|
+
// with MCP-discovered content tools. See definition.ts / mcp.ts / harness.ts.
|
|
4
|
+
//
|
|
5
|
+
// Authoring friction note (WP7 input; real observations from writing this
|
|
6
|
+
// agent):
|
|
7
|
+
//
|
|
8
|
+
// 1. MCP discovery and Agent authoring do not meet in the type system. The
|
|
9
|
+
// connector's validated Toolkit arrives as `Toolkit.Any`, while
|
|
10
|
+
// `Agent.define` needs the statically typed toolkit — so the binding
|
|
11
|
+
// between "what discovery served" and "what the child was authored
|
|
12
|
+
// against" had to be re-proved by hand (`assertDiscoveryMatchesAuthoredToolkit`
|
|
13
|
+
// re-deriving `Tool.getJsonSchema` on both sides). A framework helper that
|
|
14
|
+
// checks a `McpConnection` against a static Toolkit value (or a typed
|
|
15
|
+
// `connectMcp(request, expectedToolkit)`) would remove a whole class of
|
|
16
|
+
// look-alike-toolkit mistakes.
|
|
17
|
+
// 2. The durable delegation declaration (`SubagentRuntimeOptions.durable
|
|
18
|
+
// .targetDigests`) and the host's `DurableWorkerBinding.make(binding,
|
|
19
|
+
// digests)` registration must agree byte-for-byte, but nothing shares the
|
|
20
|
+
// value: the fixture exports both a string form and a decoded
|
|
21
|
+
// `DefinitionDigests` form of the same digests to keep them from drifting.
|
|
22
|
+
// One authoritative exported value consumed by both sides would be better.
|
|
23
|
+
// 3. Prompt-aware scripted models are boilerplate-heavy: every durable fixture
|
|
24
|
+
// (S2 travel planner, P6 planner, this one) re-implements "counters outside
|
|
25
|
+
// the Layer + decide-from-prompt". A shared `makePromptAwareCountingModel`
|
|
26
|
+
// in the testing package is an easy WP7 simplification.
|
|
27
|
+
// 4. The delegation surface itself (Subagent.define + projections + policy)
|
|
28
|
+
// was pleasant to author a second time — bounds and declassification live
|
|
29
|
+
// exactly where a reviewer looks for them.
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
export * from "./definition.ts";
|
|
33
|
+
export * from "./harness.ts";
|
|
34
|
+
export * from "./mcp.ts";
|