@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,112 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Phase 7 (P7): the Travel Planner as an INTERNAL agent with two explicit
|
|
5
|
+
// profiles (ROADMAP P7 exit gate: "Travel Planner ... retains a deterministic
|
|
6
|
+
// offline conformance profile alongside its live integration profiles").
|
|
7
|
+
//
|
|
8
|
+
// - The OFFLINE profile is the cumulative P1–P6/S1/S2 suite set that already
|
|
9
|
+
// exists: scripted `LanguageModel`, deterministic supplier Layers,
|
|
10
|
+
// controllable time and IDs, no network, no credentials. Nothing in P7
|
|
11
|
+
// weakens it; this module only pins the claim as a Schema value so evidence
|
|
12
|
+
// can cite one committed fact instead of prose.
|
|
13
|
+
// - The LIVE profile is opt-in and test-side: a suite gates itself with
|
|
14
|
+
// `describe.skipIf(...)` on the environment predicate below, so ordinary
|
|
15
|
+
// `bun run test` never makes a network request and never needs a credential.
|
|
16
|
+
// Live profiles bind live MODEL Layers only. No real travel supplier exists
|
|
17
|
+
// to integrate, so the deterministic supplier desk is retained deliberately
|
|
18
|
+
// and `liveSupplierLayers` is pinned `false` — the roadmap's "selected
|
|
19
|
+
// supplier Layers" is honestly scoped to live-model-only (P7 plan decision
|
|
20
|
+
// 9), recorded here rather than silently claimed.
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The P7 dual-profile claim, schema-first so the exact scope of "live
|
|
25
|
+
* integration profiles" is a committed, decodable value:
|
|
26
|
+
*
|
|
27
|
+
* - `offlineConformanceDeterministic` / `offlineRequiresCredentials`: the
|
|
28
|
+
* cumulative conformance suites stay deterministic and credential-free.
|
|
29
|
+
* - `liveProfileOptIn`: live suites are excluded from ordinary gates by the
|
|
30
|
+
* environment predicate (`phase7LiveProfileEnabled`), never by test-runner
|
|
31
|
+
* configuration that could silently drift.
|
|
32
|
+
* - `liveModelLayers` / `liveSupplierLayers`: live profiles exercise real
|
|
33
|
+
* model Layers over the SAME deterministic supplier desk — no claim of a
|
|
34
|
+
* live supplier integration is made anywhere (decision 9).
|
|
35
|
+
* - `structurallyRedactedTranscripts`: transcript evidence a live profile
|
|
36
|
+
* emits passes through the structural `Redactor` first (SEC-008,
|
|
37
|
+
* testing.md §12: "live model and supplier profiles are opt-in smoke or
|
|
38
|
+
* release tests, rate-limited and structurally redacted").
|
|
39
|
+
* - `exactlyOnceExternalEffects`: never claimed at any phase (DUR-003).
|
|
40
|
+
*/
|
|
41
|
+
export class TravelPlannerPhase7Profile extends Schema.Class<TravelPlannerPhase7Profile>(
|
|
42
|
+
"@effect-agent/testing/travel-planner/TravelPlannerPhase7Profile",
|
|
43
|
+
)({
|
|
44
|
+
phase: Schema.Literal("P7"),
|
|
45
|
+
offlineConformanceDeterministic: Schema.Literal(true),
|
|
46
|
+
offlineRequiresCredentials: Schema.Literal(false),
|
|
47
|
+
liveProfileOptIn: Schema.Literal(true),
|
|
48
|
+
liveModelLayers: Schema.Literal(true),
|
|
49
|
+
liveSupplierLayers: Schema.Literal(false),
|
|
50
|
+
structurallyRedactedTranscripts: Schema.Literal(true),
|
|
51
|
+
exactlyOnceExternalEffects: Schema.Literal(false),
|
|
52
|
+
}) {}
|
|
53
|
+
|
|
54
|
+
export const phase7TravelPlannerProfile = TravelPlannerPhase7Profile.make({
|
|
55
|
+
phase: "P7",
|
|
56
|
+
offlineConformanceDeterministic: true,
|
|
57
|
+
offlineRequiresCredentials: false,
|
|
58
|
+
liveProfileOptIn: true,
|
|
59
|
+
liveModelLayers: true,
|
|
60
|
+
liveSupplierLayers: false,
|
|
61
|
+
structurallyRedactedTranscripts: true,
|
|
62
|
+
exactlyOnceExternalEffects: false,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The one opt-in switch for EVERY live profile in this repository. `"1"` is
|
|
67
|
+
* the only enabling value: an unset, empty, or differently-truthy value keeps
|
|
68
|
+
* the suite skipped, so CI and ordinary developer runs stay offline.
|
|
69
|
+
*/
|
|
70
|
+
export const PHASE7_LIVE_GATE_ENV = "EFFECT_AGENT_LIVE";
|
|
71
|
+
|
|
72
|
+
/** The credential a Travel Planner live-model profile additionally requires. */
|
|
73
|
+
export const PHASE7_LIVE_CREDENTIAL_ENV = "OPENAI_API_KEY";
|
|
74
|
+
|
|
75
|
+
/** Structural shape of `process.env` without importing Node types here. */
|
|
76
|
+
export interface Phase7LiveGateEnvironment {
|
|
77
|
+
readonly [name: string]: string | undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The test-side live gate (P7 plan §6: no test-side live-gating pattern
|
|
82
|
+
* existed before this — the demo gates at serve time via
|
|
83
|
+
* `Config.redacted("OPENAI_API_KEY")`). Suites use it as
|
|
84
|
+
* `describe.skipIf(!phase7LiveProfileEnabled(process.env))`, which keeps the
|
|
85
|
+
* live block out of ordinary gates while the SAME file's ungated tests keep
|
|
86
|
+
* pinning the profile schema on every run.
|
|
87
|
+
*/
|
|
88
|
+
export const phase7LiveProfileEnabled = (env: Phase7LiveGateEnvironment): boolean =>
|
|
89
|
+
env[PHASE7_LIVE_GATE_ENV] === "1" && (env[PHASE7_LIVE_CREDENTIAL_ENV] ?? "") !== "";
|
|
90
|
+
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
// Authoring friction note (WP7 input; real observations from wiring the P7
|
|
93
|
+
// live profile onto the existing Travel Planner):
|
|
94
|
+
//
|
|
95
|
+
// 1. There was no framework-owned place to put a test-side live gate: every
|
|
96
|
+
// earlier profile was either always-deterministic or gated at serve time
|
|
97
|
+
// inside an application. The predicate had to be invented here as a plain
|
|
98
|
+
// exported function because `packages/testing/src` must stay
|
|
99
|
+
// platform-neutral and cannot read `process.env` itself — fine, but the
|
|
100
|
+
// split (fixture exports the predicate, the example test applies it to
|
|
101
|
+
// `process.env`) is a convention a future author has to discover by
|
|
102
|
+
// reading this file rather than a typed seam.
|
|
103
|
+
// 2. Capturing a structurally redacted transcript from a live Run takes
|
|
104
|
+
// manual assembly: collect `RunEvent`s, `Schema.encode` each one, pass the
|
|
105
|
+
// encoded value through `Redactor.redact`. Nothing composes those three
|
|
106
|
+
// steps, and nothing type-level stops a test from logging the RAW event by
|
|
107
|
+
// accident. A `redactedTranscript(events)` helper in capabilities (or an
|
|
108
|
+
// engine stream combinator) would make the safe path the short path.
|
|
109
|
+
// 3. Binding a live model to the existing definition was pleasantly trivial
|
|
110
|
+
// (`Agent.withModel` + the upstream client Layer): no friction to report
|
|
111
|
+
// on the authoring surface itself for plain ephemeral runs.
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
|
|
3
|
+
import type { ScriptedTurnInput } from "../../scripted-model.ts";
|
|
4
|
+
import { TravelPlan, TripRequest, type TravelPlan as TravelPlanValue } from "./definition.ts";
|
|
5
|
+
|
|
6
|
+
const usage = { inputTokens: { total: 128 }, outputTokens: { total: 96 } };
|
|
7
|
+
export const phase1Trip = Schema.decodeSync(TripRequest)({
|
|
8
|
+
request:
|
|
9
|
+
"Plan a review-only London trip using the deterministic flight, lodging, and activity searches.",
|
|
10
|
+
origin: "SFO",
|
|
11
|
+
destination: "LHR",
|
|
12
|
+
departOn: "2026-09-14",
|
|
13
|
+
nights: 4,
|
|
14
|
+
travelers: 2,
|
|
15
|
+
budgetCents: 350_000,
|
|
16
|
+
currency: "USD",
|
|
17
|
+
});
|
|
18
|
+
/** Backward-compatible fixture alias while consumers transition to the P1 name. */
|
|
19
|
+
export const phase0Trip = phase1Trip;
|
|
20
|
+
export const expectedTravelPlan: TravelPlanValue = Schema.decodeSync(TravelPlan)({
|
|
21
|
+
itineraries: [
|
|
22
|
+
{
|
|
23
|
+
title: "Westward light, eastbound overnight",
|
|
24
|
+
route: "San Francisco → London",
|
|
25
|
+
dates: "14–19 September 2026",
|
|
26
|
+
flight: "EA 218 · nonstop · SFO 18:40 → LHR 13:05+1",
|
|
27
|
+
lodging: "Bloomsbury House · refundable studio · 4 nights",
|
|
28
|
+
activities: ["British Museum timed entry", "Thames evening walk"],
|
|
29
|
+
estimatedTotalCents: 284_000,
|
|
30
|
+
currency: "USD",
|
|
31
|
+
quoteId: "quote-sfo-lhr-001",
|
|
32
|
+
assumptions: [
|
|
33
|
+
"Two travelers sharing one studio",
|
|
34
|
+
"Quote is read-only availability, not a reservation",
|
|
35
|
+
],
|
|
36
|
+
unresolvedConstraints: [
|
|
37
|
+
"Traveler names and accessibility requests are intentionally omitted",
|
|
38
|
+
],
|
|
39
|
+
nextAction: "review",
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
export const phase1HappyPathTurns = [
|
|
45
|
+
{
|
|
46
|
+
_tag: "Stream",
|
|
47
|
+
parts: [
|
|
48
|
+
{
|
|
49
|
+
type: "tool-call",
|
|
50
|
+
id: "flight-call-1",
|
|
51
|
+
name: "search_flights",
|
|
52
|
+
params: { origin: "SFO", destination: "LHR", departOn: "2026-09-14", travelers: 2 },
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
type: "tool-call",
|
|
56
|
+
id: "lodging-call-1",
|
|
57
|
+
name: "search_lodging",
|
|
58
|
+
params: { destination: "LHR", departOn: "2026-09-14", nights: 4, travelers: 2 },
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
type: "tool-call",
|
|
62
|
+
id: "activity-call-1",
|
|
63
|
+
name: "search_activities",
|
|
64
|
+
params: { destination: "LHR", departOn: "2026-09-14", nights: 4, travelers: 2 },
|
|
65
|
+
},
|
|
66
|
+
{ type: "finish", reason: "tool-calls", usage },
|
|
67
|
+
],
|
|
68
|
+
termination: { _tag: "Complete" },
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
_tag: "Stream",
|
|
72
|
+
parts: [
|
|
73
|
+
{ type: "text-start", id: "itinerary-json" },
|
|
74
|
+
{
|
|
75
|
+
type: "text-delta",
|
|
76
|
+
id: "itinerary-json",
|
|
77
|
+
delta: JSON.stringify(Schema.encodeSync(TravelPlan)(expectedTravelPlan)),
|
|
78
|
+
},
|
|
79
|
+
{ type: "text-end", id: "itinerary-json" },
|
|
80
|
+
{ type: "finish", reason: "stop", usage },
|
|
81
|
+
],
|
|
82
|
+
termination: { _tag: "Complete" },
|
|
83
|
+
},
|
|
84
|
+
] satisfies readonly [ScriptedTurnInput, ScriptedTurnInput];
|
|
85
|
+
export const phase0HappyPathTurns = phase1HappyPathTurns;
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
import {
|
|
2
|
+
delegationAllocationFromPolicy,
|
|
3
|
+
SubagentReservationsMemoryLive,
|
|
4
|
+
SubagentRuntime,
|
|
5
|
+
} from "@effect-agent/capabilities";
|
|
6
|
+
import { Agent, type ConversationId } from "@effect-agent/core";
|
|
7
|
+
import type { RuntimeBinding } from "@effect-agent/engine";
|
|
8
|
+
import {
|
|
9
|
+
DefinitionDigests,
|
|
10
|
+
DeploymentId,
|
|
11
|
+
Digest,
|
|
12
|
+
DurableWorkerBinding,
|
|
13
|
+
Principal,
|
|
14
|
+
ProducerId,
|
|
15
|
+
type DurableSubmitOptions,
|
|
16
|
+
type IdempotencyKey,
|
|
17
|
+
type ResolvedBinding,
|
|
18
|
+
} from "@effect-agent/session";
|
|
19
|
+
import { Effect, Layer, Ref, Schema, Stream } from "effect";
|
|
20
|
+
import { LanguageModel, Model, type Response, type Toolkit } from "effect/unstable/ai";
|
|
21
|
+
|
|
22
|
+
import { DeterministicIdGeneratorLayer } from "./deterministic-layers.ts";
|
|
23
|
+
import {
|
|
24
|
+
DestinationBrief,
|
|
25
|
+
DestinationFacts,
|
|
26
|
+
DestinationGuide,
|
|
27
|
+
DestinationRecommendation,
|
|
28
|
+
DestinationReport,
|
|
29
|
+
DestinationResearcher,
|
|
30
|
+
DestinationResearcherToolkit,
|
|
31
|
+
DestinationResearcherToolkitLayer,
|
|
32
|
+
destinationLookup,
|
|
33
|
+
destinationReportFor,
|
|
34
|
+
destinationResearchDelegation,
|
|
35
|
+
destinationResearchPolicy,
|
|
36
|
+
DestinationShortlist,
|
|
37
|
+
encodedDestinationReport,
|
|
38
|
+
mapResearchChildFailure,
|
|
39
|
+
ResearchDispatchGate,
|
|
40
|
+
TravelCoordinator,
|
|
41
|
+
} from "./subagents.ts";
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// S2 durability profile (spec/subagents.md §17): the S1 coordinator →
|
|
45
|
+
// destination-researcher delegation re-run as ACCEPTED WORK on the Node/SQLite
|
|
46
|
+
// runtime. The claim is `DN` durable attached Subagents only: establishment
|
|
47
|
+
// and join are replay-safe by construction (SUB-016/SUB-019), a completed
|
|
48
|
+
// child is never re-executed on a lost join acknowledgment, and NO claim of
|
|
49
|
+
// exactly-once child external effects is made (rule 8; an unresolved ordinary
|
|
50
|
+
// child Tool blocks as an Unknown Outcome instead, SUB-021). Cloudflare
|
|
51
|
+
// equivalence is P6 scope and explicitly not claimed here.
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
export class TravelPlannerSubagentDurabilityProfile extends Schema.Class<TravelPlannerSubagentDurabilityProfile>(
|
|
55
|
+
"@effect-agent/testing/travel-planner/TravelPlannerSubagentDurabilityProfile",
|
|
56
|
+
)({
|
|
57
|
+
deploymentClass: Schema.Literal("DN"),
|
|
58
|
+
durableAttachedSubagents: Schema.Literal(true),
|
|
59
|
+
canonicalSchemaVersion: Schema.Literal(1),
|
|
60
|
+
/** Establishment/join replay converges on one child Receipt, Conversation, and join batch. */
|
|
61
|
+
subagentReplaySafe: Schema.Literal(true),
|
|
62
|
+
/** Never claimed (rule 8): child ordinary Tools stop at Unknown Outcomes, they do not replay. */
|
|
63
|
+
childExternalEffectsExactlyOnce: Schema.Literal(false),
|
|
64
|
+
/** The same conformance suite under DO eviction/alarms is P6 scope (spec §17 `DC`). */
|
|
65
|
+
cloudflareEquivalence: Schema.Literal(false),
|
|
66
|
+
}) {}
|
|
67
|
+
|
|
68
|
+
export const s2TravelPlannerProfile = TravelPlannerSubagentDurabilityProfile.make({
|
|
69
|
+
deploymentClass: "DN",
|
|
70
|
+
durableAttachedSubagents: true,
|
|
71
|
+
canonicalSchemaVersion: 1,
|
|
72
|
+
subagentReplaySafe: true,
|
|
73
|
+
childExternalEffectsExactlyOnce: false,
|
|
74
|
+
cloudflareEquivalence: false,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
export const s2TravelPlannerDeploymentId = Schema.decodeSync(DeploymentId)(
|
|
78
|
+
"travel-planner-s2-deployment",
|
|
79
|
+
);
|
|
80
|
+
export const s2TravelPlannerProducerId = Schema.decodeSync(ProducerId)(
|
|
81
|
+
"travel-planner-s2-producer",
|
|
82
|
+
);
|
|
83
|
+
export const s2TravelPlannerPrincipal = Schema.decodeSync(Principal)("travel-planner-s2-principal");
|
|
84
|
+
|
|
85
|
+
const digestOf = (character: string) => Schema.decodeSync(Digest)(character.repeat(64));
|
|
86
|
+
|
|
87
|
+
/** Redacted, deterministic parent (coordinator) definition digests for this fixture version. */
|
|
88
|
+
export const s2CoordinatorDigests = DefinitionDigests.make({
|
|
89
|
+
agent: digestOf("a"),
|
|
90
|
+
model: digestOf("b"),
|
|
91
|
+
tools: digestOf("c"),
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The exact child Binding digest strings the application declares on
|
|
96
|
+
* `SubagentRuntimeOptions.durable.targetDigests` AND the host registers with
|
|
97
|
+
* the `AgentBindingResolver` for the researcher Binding. The coordinator
|
|
98
|
+
* stores and verifies them byte-for-byte (SUB-023); a host registration under
|
|
99
|
+
* different strings is a `ChildCompatibilityFailure`, never a substitution.
|
|
100
|
+
*/
|
|
101
|
+
export const s2ResearcherDigestStrings = {
|
|
102
|
+
agent: "d".repeat(64),
|
|
103
|
+
model: "e".repeat(64),
|
|
104
|
+
tools: "f".repeat(64),
|
|
105
|
+
} as const;
|
|
106
|
+
|
|
107
|
+
export const s2ResearcherDigests = DefinitionDigests.make({
|
|
108
|
+
agent: Schema.decodeSync(Digest)(s2ResearcherDigestStrings.agent),
|
|
109
|
+
model: Schema.decodeSync(Digest)(s2ResearcherDigestStrings.model),
|
|
110
|
+
tools: Schema.decodeSync(Digest)(s2ResearcherDigestStrings.tools),
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
/** Durable admission options for one coordinator Submission on one mission lane. */
|
|
114
|
+
export const s2TravelPlannerSubmitOptions = (
|
|
115
|
+
conversationId: ConversationId,
|
|
116
|
+
idempotencyKey: IdempotencyKey,
|
|
117
|
+
): DurableSubmitOptions => ({
|
|
118
|
+
conversationId,
|
|
119
|
+
principal: s2TravelPlannerPrincipal,
|
|
120
|
+
idempotencyKey,
|
|
121
|
+
definitions: s2CoordinatorDigests,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
/** The structural submit slice of the coordinator Binding (`DurableAgentRuntime.submit`). */
|
|
125
|
+
export const s2CoordinatorSubmitAgent = {
|
|
126
|
+
definition: { id: TravelCoordinator.id, input: TravelCoordinator.input },
|
|
127
|
+
} as const;
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The per-invocation reservation the durable handler computes from the S1
|
|
131
|
+
* delegation policy (`delegationAllocationFromPolicy`): the conservation
|
|
132
|
+
* evidence in the S2 tests checks the ledger reservation rows and the
|
|
133
|
+
* canonical `SubagentJoined.finalAccounting` against exactly this value.
|
|
134
|
+
*/
|
|
135
|
+
export const durableResearchAllocation = delegationAllocationFromPolicy(destinationResearchPolicy);
|
|
136
|
+
|
|
137
|
+
/** The one scripted delegation Tool Call id of the durable coordinator Run. */
|
|
138
|
+
export const durableResearchCallId = "research-lhr-1";
|
|
139
|
+
|
|
140
|
+
/** The child's own scripted guide-lookup Tool Call id. */
|
|
141
|
+
export const durableChildLookupCallId = (destination: string): string => `lookup-${destination}`;
|
|
142
|
+
|
|
143
|
+
/** The projected finding the parent joins (only the advisory crosses, SUB-015). */
|
|
144
|
+
export const durableResearchFinding = (destination: string) => ({
|
|
145
|
+
destination: destinationReportFor(destination).destination,
|
|
146
|
+
summary: destinationReportFor(destination).advisory,
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
/** The coordinator's expected final shortlist for one researched destination. */
|
|
150
|
+
export const durableResearchShortlist = (destination: string): DestinationShortlist =>
|
|
151
|
+
DestinationShortlist.make({
|
|
152
|
+
recommendations: [
|
|
153
|
+
DestinationRecommendation.make({
|
|
154
|
+
destination: destinationReportFor(destination).destination,
|
|
155
|
+
summary: destinationReportFor(destination).advisory,
|
|
156
|
+
}),
|
|
157
|
+
],
|
|
158
|
+
nextAction: "review",
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The deterministic guide facts in encoded (wire) form: the "supplier truth"
|
|
163
|
+
* an authorized operator records through `resolveUnknown` when a child guide
|
|
164
|
+
* lookup stopped at an Unknown Outcome (DUR-017 — the framework never guesses
|
|
165
|
+
* or replays it).
|
|
166
|
+
*/
|
|
167
|
+
export const encodedDestinationFacts = (destination: string): unknown => {
|
|
168
|
+
const report = destinationReportFor(destination);
|
|
169
|
+
return Schema.encodeSync(DestinationFacts)(
|
|
170
|
+
DestinationFacts.make({
|
|
171
|
+
destination: report.destination,
|
|
172
|
+
highlights: report.highlights,
|
|
173
|
+
advisory: report.advisory,
|
|
174
|
+
}),
|
|
175
|
+
);
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
// Invocation-counting scripted models (the P5 SupplierBookingDesk counter
|
|
180
|
+
// pattern): the call counter and captured prompts live OUTSIDE the Model
|
|
181
|
+
// Layer, so they survive Layer rebuilds across Attempts and across separate
|
|
182
|
+
// runtime handles over the same SQLite file — "the child was never
|
|
183
|
+
// re-executed" is asserted, not assumed.
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
const scriptedUsage = { inputTokens: { total: 96 }, outputTokens: { total: 64 } };
|
|
187
|
+
|
|
188
|
+
/** One scripted model whose behavior is keyed by the global invocation index. */
|
|
189
|
+
export const makeInvocationCountingModel = (
|
|
190
|
+
name: string,
|
|
191
|
+
script: (call: number) => ReadonlyArray<Response.StreamPartEncoded>,
|
|
192
|
+
) =>
|
|
193
|
+
Effect.gen(function* () {
|
|
194
|
+
const calls = yield* Ref.make(0);
|
|
195
|
+
const prompts = yield* Ref.make<ReadonlyArray<string>>([]);
|
|
196
|
+
const model = Model.make(
|
|
197
|
+
"scripted",
|
|
198
|
+
name,
|
|
199
|
+
Layer.effect(
|
|
200
|
+
LanguageModel.LanguageModel,
|
|
201
|
+
LanguageModel.make({
|
|
202
|
+
generateText: () => Effect.succeed([]),
|
|
203
|
+
streamText: (request) =>
|
|
204
|
+
Stream.unwrap(
|
|
205
|
+
Effect.gen(function* () {
|
|
206
|
+
const call = yield* Ref.getAndUpdate(calls, (value) => value + 1);
|
|
207
|
+
yield* Ref.update(prompts, (previous) => [
|
|
208
|
+
...previous,
|
|
209
|
+
JSON.stringify(request.prompt.content),
|
|
210
|
+
]);
|
|
211
|
+
return Stream.fromIterable(script(call));
|
|
212
|
+
}),
|
|
213
|
+
),
|
|
214
|
+
}),
|
|
215
|
+
),
|
|
216
|
+
);
|
|
217
|
+
return { model, calls: Ref.get(calls), prompts: Ref.get(prompts) };
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
const delegationTurnParts = (
|
|
221
|
+
toolCallId: string,
|
|
222
|
+
destination: string,
|
|
223
|
+
focus: string,
|
|
224
|
+
): ReadonlyArray<Response.StreamPartEncoded> => [
|
|
225
|
+
{
|
|
226
|
+
type: "tool-call",
|
|
227
|
+
id: toolCallId,
|
|
228
|
+
name: "delegate_destination_research",
|
|
229
|
+
params: { destination, focus },
|
|
230
|
+
providerExecuted: false,
|
|
231
|
+
},
|
|
232
|
+
{ type: "finish", reason: "tool-calls", usage: scriptedUsage },
|
|
233
|
+
];
|
|
234
|
+
|
|
235
|
+
const shortlistParts = (
|
|
236
|
+
shortlist: DestinationShortlist,
|
|
237
|
+
): ReadonlyArray<Response.StreamPartEncoded> => [
|
|
238
|
+
{ type: "text-start", id: "shortlist" },
|
|
239
|
+
{
|
|
240
|
+
type: "text-delta",
|
|
241
|
+
id: "shortlist",
|
|
242
|
+
delta: JSON.stringify(Schema.encodeSync(DestinationShortlist)(shortlist)),
|
|
243
|
+
},
|
|
244
|
+
{ type: "text-end", id: "shortlist" },
|
|
245
|
+
{ type: "finish", reason: "stop", usage: scriptedUsage },
|
|
246
|
+
];
|
|
247
|
+
|
|
248
|
+
const researcherLookupParts = (destination: string): ReadonlyArray<Response.StreamPartEncoded> => [
|
|
249
|
+
{
|
|
250
|
+
type: "tool-call",
|
|
251
|
+
id: durableChildLookupCallId(destination),
|
|
252
|
+
name: "lookup_destination",
|
|
253
|
+
params: { destination },
|
|
254
|
+
providerExecuted: false,
|
|
255
|
+
},
|
|
256
|
+
{ type: "finish", reason: "tool-calls", usage: scriptedUsage },
|
|
257
|
+
];
|
|
258
|
+
|
|
259
|
+
const researcherReportParts = (destination: string): ReadonlyArray<Response.StreamPartEncoded> => [
|
|
260
|
+
{ type: "text-start", id: "destination-report" },
|
|
261
|
+
{ type: "text-delta", id: "destination-report", delta: encodedDestinationReport(destination) },
|
|
262
|
+
{ type: "text-end", id: "destination-report" },
|
|
263
|
+
{ type: "finish", reason: "stop", usage: scriptedUsage },
|
|
264
|
+
];
|
|
265
|
+
|
|
266
|
+
// ---------------------------------------------------------------------------
|
|
267
|
+
// Durable delegation wiring (S2): the SAME immutable S1 Delegation Definition
|
|
268
|
+
// paired with one explicit child Binding, now carrying the construction-fixed
|
|
269
|
+
// durable declaration. Under a durable coordinator the handler establishes an
|
|
270
|
+
// accepted-work child instead of spawning an in-process fiber; without the
|
|
271
|
+
// declaration a durable-mode invocation fails closed (WP5 contract).
|
|
272
|
+
// ---------------------------------------------------------------------------
|
|
273
|
+
|
|
274
|
+
/** Runtime wiring for the durable slice: the S1 delegation plus the S2 digest declaration. */
|
|
275
|
+
export const durableDestinationResearchHandlersLayer = <Provider, ModelProvides, ModelRequires>(
|
|
276
|
+
childBinding: RuntimeBinding<
|
|
277
|
+
typeof DestinationBrief,
|
|
278
|
+
typeof DestinationReport,
|
|
279
|
+
string,
|
|
280
|
+
Toolkit.Tools<typeof DestinationResearcherToolkit>,
|
|
281
|
+
Provider,
|
|
282
|
+
ModelProvides,
|
|
283
|
+
ModelRequires
|
|
284
|
+
>,
|
|
285
|
+
) =>
|
|
286
|
+
SubagentRuntime.layer(destinationResearchDelegation, childBinding, {
|
|
287
|
+
mapChildFailure: mapResearchChildFailure,
|
|
288
|
+
durable: { targetDigests: s2ResearcherDigestStrings },
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
/** Optional overrides for one durable research harness. */
|
|
292
|
+
export interface DurableResearchHarnessOptions {
|
|
293
|
+
/** Researched destination; defaults to "LHR". */
|
|
294
|
+
readonly destination?: string | undefined;
|
|
295
|
+
/** Delegation focus; defaults to "museums". */
|
|
296
|
+
readonly focus?: string | undefined;
|
|
297
|
+
/**
|
|
298
|
+
* Digests the HOST registers the child Binding under; defaults to the exact
|
|
299
|
+
* declared `s2ResearcherDigests`. Register different digests to force the
|
|
300
|
+
* fail-closed `ChildCompatibilityFailure` path (SUB-023/SUB-032).
|
|
301
|
+
*/
|
|
302
|
+
readonly childRegistrationDigests?: DefinitionDigests | undefined;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** One durable coordinator/researcher pair with observable invocation counters. */
|
|
306
|
+
export interface DurableResearchHarness {
|
|
307
|
+
/** Host registrations for `NodeDurableRuntimeOptions.bindings` (parent + child). */
|
|
308
|
+
readonly bindings: ReadonlyArray<ResolvedBinding>;
|
|
309
|
+
/** Total coordinator model invocations across every Attempt and runtime handle. */
|
|
310
|
+
readonly parentModelCalls: Effect.Effect<number>;
|
|
311
|
+
/** JSON-encoded coordinator prompts in request order. */
|
|
312
|
+
readonly parentPrompts: Effect.Effect<ReadonlyArray<string>>;
|
|
313
|
+
/** Total researcher model invocations across every Attempt and runtime handle. */
|
|
314
|
+
readonly childModelCalls: Effect.Effect<number>;
|
|
315
|
+
/** JSON-encoded researcher prompts in request order (context-isolation evidence). */
|
|
316
|
+
readonly childPrompts: Effect.Effect<ReadonlyArray<string>>;
|
|
317
|
+
/** Deterministic guide-lookup handler executions (ordinary child Tool side effects). */
|
|
318
|
+
readonly guideInvocations: Effect.Effect<number>;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Build the S2 Travel Planner harness: an invocation-counting scripted
|
|
323
|
+
* coordinator (Turn 1 declares the one delegation call, Turn 2 writes the
|
|
324
|
+
* shortlist), an invocation-counting scripted researcher (Turn 1 consults the
|
|
325
|
+
* guide, Turn 2 writes the report), and both worker Bindings captured with
|
|
326
|
+
* their requirement Contexts via `DurableWorkerBinding.make` under the exact
|
|
327
|
+
* fixture digests. The returned `bindings` are plain values: they can be
|
|
328
|
+
* registered with several `NodeDurableRuntime` stacks over the same SQLite
|
|
329
|
+
* file while the counters keep counting across all of them.
|
|
330
|
+
*/
|
|
331
|
+
export const makeDurableResearchHarness = (options?: DurableResearchHarnessOptions) =>
|
|
332
|
+
Effect.gen(function* () {
|
|
333
|
+
const destination = options?.destination ?? "LHR";
|
|
334
|
+
const focus = options?.focus ?? "museums";
|
|
335
|
+
|
|
336
|
+
const guideInvocations = yield* Ref.make(0);
|
|
337
|
+
const guideLayer = Layer.succeed(
|
|
338
|
+
DestinationGuide,
|
|
339
|
+
DestinationGuide.of({
|
|
340
|
+
lookup: (query) =>
|
|
341
|
+
Ref.update(guideInvocations, (count) => count + 1).pipe(
|
|
342
|
+
Effect.andThen(destinationLookup(query)),
|
|
343
|
+
),
|
|
344
|
+
}),
|
|
345
|
+
);
|
|
346
|
+
const childToolkitLayer = DestinationResearcherToolkitLayer.pipe(
|
|
347
|
+
Layer.provideMerge(guideLayer),
|
|
348
|
+
);
|
|
349
|
+
|
|
350
|
+
const childModel = yield* makeInvocationCountingModel("destination-researcher-s2", (call) =>
|
|
351
|
+
call === 0 ? researcherLookupParts(destination) : researcherReportParts(destination),
|
|
352
|
+
);
|
|
353
|
+
const childBinding = Agent.withModel(DestinationResearcher, childModel.model);
|
|
354
|
+
|
|
355
|
+
const parentModel = yield* makeInvocationCountingModel("travel-coordinator-s2", (call) =>
|
|
356
|
+
call === 0
|
|
357
|
+
? delegationTurnParts(durableResearchCallId, destination, focus)
|
|
358
|
+
: shortlistParts(durableResearchShortlist(destination)),
|
|
359
|
+
);
|
|
360
|
+
const parentBinding = Agent.withModel(TravelCoordinator, parentModel.model);
|
|
361
|
+
|
|
362
|
+
const delegationLayer = durableDestinationResearchHandlersLayer(childBinding).pipe(
|
|
363
|
+
Layer.provide(
|
|
364
|
+
Layer.mergeAll(
|
|
365
|
+
childToolkitLayer,
|
|
366
|
+
SubagentReservationsMemoryLive,
|
|
367
|
+
DeterministicIdGeneratorLayer,
|
|
368
|
+
ResearchDispatchGate.layerOpen,
|
|
369
|
+
),
|
|
370
|
+
),
|
|
371
|
+
);
|
|
372
|
+
|
|
373
|
+
const parentResolved: ResolvedBinding = yield* DurableWorkerBinding.make(
|
|
374
|
+
parentBinding,
|
|
375
|
+
s2CoordinatorDigests,
|
|
376
|
+
).pipe(Effect.provide(delegationLayer));
|
|
377
|
+
const childResolved: ResolvedBinding = yield* DurableWorkerBinding.make(
|
|
378
|
+
childBinding,
|
|
379
|
+
options?.childRegistrationDigests ?? s2ResearcherDigests,
|
|
380
|
+
).pipe(Effect.provide(childToolkitLayer));
|
|
381
|
+
|
|
382
|
+
const harness: DurableResearchHarness = {
|
|
383
|
+
bindings: [parentResolved, childResolved],
|
|
384
|
+
parentModelCalls: parentModel.calls,
|
|
385
|
+
parentPrompts: parentModel.prompts,
|
|
386
|
+
childModelCalls: childModel.calls,
|
|
387
|
+
childPrompts: childModel.prompts,
|
|
388
|
+
guideInvocations: Ref.get(guideInvocations),
|
|
389
|
+
};
|
|
390
|
+
return harness;
|
|
391
|
+
});
|