@nebutra/agent-runtime 0.2.1 → 0.2.2
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/LICENSE +21 -676
- package/dist/adapters/index.d.ts +24 -5
- package/dist/adapters/index.js +27 -0
- package/dist/adapters/index.js.map +1 -1
- package/dist/chunk-Q62VKHIT.js +178 -0
- package/dist/chunk-Q62VKHIT.js.map +1 -0
- package/dist/{chunk-MUF7ZZTO.js → chunk-R5HOSQUW.js} +2 -1
- package/dist/{chunk-MUF7ZZTO.js.map → chunk-R5HOSQUW.js.map} +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +14 -17
- package/dist/index.js.map +1 -1
- package/dist/orchestration.d.ts +42 -1
- package/dist/orchestration.js +7 -3
- package/dist/sandbox.js +1 -1
- package/package.json +80 -30
- package/.turbo/turbo-build.log +0 -130
- package/.turbo/turbo-test.log +0 -47
- package/.turbo/turbo-typecheck.log +0 -4
- package/CHANGELOG.md +0 -250
- package/dist/chunk-MX2WL43P.js +0 -90
- package/dist/chunk-MX2WL43P.js.map +0 -1
- package/examples/pulsar-quickstart.ts +0 -35
- package/examples/resume-branch.ts +0 -45
- package/examples/subagent-fanout.ts +0 -20
- package/src/adapters/dispatcher-sse.test.ts +0 -218
- package/src/adapters/dispatcher-sse.ts +0 -222
- package/src/adapters/index.ts +0 -18
- package/src/adapters/mcp-catalog.test.ts +0 -213
- package/src/adapters/mcp-catalog.ts +0 -188
- package/src/adapters/prisma-rollout.test.ts +0 -153
- package/src/adapters/prisma-rollout.ts +0 -104
- package/src/agent-runtime.test.ts +0 -176
- package/src/artifact-stream.test.ts +0 -330
- package/src/artifact-stream.ts +0 -453
- package/src/channel-gateway.test.ts +0 -432
- package/src/channel-gateway.ts +0 -357
- package/src/cli.ts +0 -49
- package/src/code-review.test.ts +0 -501
- package/src/code-review.ts +0 -495
- package/src/command-suggestions.test.ts +0 -251
- package/src/command-suggestions.ts +0 -338
- package/src/commands.test.ts +0 -184
- package/src/commands.ts +0 -140
- package/src/commit-message.test.ts +0 -249
- package/src/commit-message.ts +0 -180
- package/src/context-compaction.test.ts +0 -522
- package/src/context-compaction.ts +0 -438
- package/src/definitions.test.ts +0 -78
- package/src/definitions.ts +0 -190
- package/src/deployment-status.test.ts +0 -215
- package/src/deployment-status.ts +0 -227
- package/src/design-context.test.ts +0 -195
- package/src/design-context.ts +0 -198
- package/src/dispatcher.test.ts +0 -234
- package/src/dispatcher.ts +0 -189
- package/src/durable-turn.test.ts +0 -209
- package/src/durable-turn.ts +0 -135
- package/src/edit-planner.test.ts +0 -204
- package/src/edit-planner.ts +0 -325
- package/src/fuzzy-match.test.ts +0 -311
- package/src/fuzzy-match.ts +0 -444
- package/src/hook-pipeline.test.ts +0 -279
- package/src/hook-pipeline.ts +0 -373
- package/src/inbound-admission.test.ts +0 -394
- package/src/inbound-admission.ts +0 -246
- package/src/index.ts +0 -49
- package/src/loop.test.ts +0 -161
- package/src/loop.ts +0 -211
- package/src/mcp-bridge.test.ts +0 -165
- package/src/mcp-bridge.ts +0 -81
- package/src/memory-provider.test.ts +0 -232
- package/src/memory-provider.ts +0 -257
- package/src/model.ts +0 -168
- package/src/orchestration.test.ts +0 -53
- package/src/orchestration.ts +0 -146
- package/src/permission-ruleset.test.ts +0 -301
- package/src/permission-ruleset.ts +0 -1
- package/src/policy.ts +0 -151
- package/src/project-repo.test.ts +0 -232
- package/src/project-repo.ts +0 -311
- package/src/protocol.ts +0 -159
- package/src/pulsar.test.ts +0 -156
- package/src/pulsar.ts +0 -322
- package/src/rollout-store-persistent.test.ts +0 -217
- package/src/rollout-store-persistent.ts +0 -166
- package/src/rollout.ts +0 -150
- package/src/sandbox.ts +0 -113
- package/src/session-share.test.ts +0 -360
- package/src/session-share.ts +0 -310
- package/src/skill-distillation.test.ts +0 -177
- package/src/skill-distillation.ts +0 -369
- package/src/skills.test.ts +0 -277
- package/src/skills.ts +0 -277
- package/src/subagents.test.ts +0 -290
- package/src/subagents.ts +0 -332
- package/src/tools.test.ts +0 -12
- package/src/tools.ts +0 -129
- package/src/workbench.test.ts +0 -0
- package/src/workbench.ts +0 -0
- package/tsconfig.json +0 -12
- package/tsup.config.ts +0 -36
|
@@ -1,177 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it, vi } from "vitest";
|
|
2
|
-
import {
|
|
3
|
-
type DistilledSkill,
|
|
4
|
-
distillSkill,
|
|
5
|
-
improveSkill,
|
|
6
|
-
isDistillable,
|
|
7
|
-
type Synthesizer,
|
|
8
|
-
shouldNudgePersist,
|
|
9
|
-
type Trajectory,
|
|
10
|
-
} from "./skill-distillation.js";
|
|
11
|
-
|
|
12
|
-
const goodTraj = (over: Partial<Trajectory> = {}): Trajectory => ({
|
|
13
|
-
tenantId: "org_1",
|
|
14
|
-
sessionId: "sess_1",
|
|
15
|
-
goal: "Refactor the billing module to use the provider-agnostic adapter",
|
|
16
|
-
outcome: "success",
|
|
17
|
-
steps: [
|
|
18
|
-
{ kind: "message", input: "start" },
|
|
19
|
-
{ kind: "tool", name: "ReadFile", input: { path: "a.ts" }, output: "ok" },
|
|
20
|
-
{ kind: "observation", output: "found 3 callers" },
|
|
21
|
-
{ kind: "tool", name: "EditFile", input: { path: "a.ts" }, output: "patched" },
|
|
22
|
-
{ kind: "tool", name: "RunTests", output: "pass" },
|
|
23
|
-
],
|
|
24
|
-
...over,
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
const fallback: Synthesizer = async () => ({
|
|
28
|
-
name: "Billing Adapter Refactor",
|
|
29
|
-
description: "Refactor billing toward the provider-agnostic adapter",
|
|
30
|
-
whenToUse: "When migrating billing code to the shared adapter",
|
|
31
|
-
body: "1. Read callers\n2. Edit\n3. Run tests",
|
|
32
|
-
// intentionally over-broad — must be clamped to used tools
|
|
33
|
-
allowedTools: ["ReadFile", "EditFile", "RunTests", "DeployProd"],
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
describe("isDistillable", () => {
|
|
37
|
-
it("rejects a failed trajectory", () => {
|
|
38
|
-
const r = isDistillable(goodTraj({ outcome: "failure" }));
|
|
39
|
-
expect(r.ok).toBe(false);
|
|
40
|
-
expect(r.reason).toBeTruthy();
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
it("rejects a partial trajectory", () => {
|
|
44
|
-
expect(isDistillable(goodTraj({ outcome: "partial" })).ok).toBe(false);
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
it("rejects too-few tool steps", () => {
|
|
48
|
-
const r = isDistillable(goodTraj({ steps: [{ kind: "tool", name: "ReadFile" }] }));
|
|
49
|
-
expect(r.ok).toBe(false);
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
it("rejects a trivial goal", () => {
|
|
53
|
-
expect(isDistillable(goodTraj({ goal: "hi" })).ok).toBe(false);
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
it("accepts a good trajectory", () => {
|
|
57
|
-
expect(isDistillable(goodTraj()).ok).toBe(true);
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
it("honors a configurable minimum tool count", () => {
|
|
61
|
-
expect(isDistillable(goodTraj(), { minToolSteps: 99 }).ok).toBe(false);
|
|
62
|
-
});
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
describe("distillSkill", () => {
|
|
66
|
-
it("clamps allowedTools to tools actually used (least privilege)", async () => {
|
|
67
|
-
const s = await distillSkill(goodTraj(), fallback);
|
|
68
|
-
expect([...s.frontmatter.allowedTools].sort()).toEqual(
|
|
69
|
-
["EditFile", "ReadFile", "RunTests"].sort(),
|
|
70
|
-
);
|
|
71
|
-
expect(s.frontmatter.allowedTools).not.toContain("DeployProd");
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
it("produces a kebab slug and carries tenant + provenance", async () => {
|
|
75
|
-
const s = await distillSkill(goodTraj(), fallback);
|
|
76
|
-
expect(s.slug).toBe("billing-adapter-refactor");
|
|
77
|
-
expect(s.tenantId).toBe("org_1");
|
|
78
|
-
expect(s.sourceTier).toBe("dynamic");
|
|
79
|
-
expect(s.provenance.sessionId).toBe("sess_1");
|
|
80
|
-
expect(s.provenance.stepCount).toBe(5);
|
|
81
|
-
expect(typeof s.provenance.distilledAt).toBe("string");
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
it("builds a deterministic prompt (snapshot) and passes it to synthesize", async () => {
|
|
85
|
-
const spy = vi.fn(fallback);
|
|
86
|
-
await distillSkill(goodTraj(), spy);
|
|
87
|
-
expect(spy).toHaveBeenCalledTimes(1);
|
|
88
|
-
expect(spy.mock.calls[0]![0]).toMatchInlineSnapshot(`
|
|
89
|
-
"Distill a reusable skill from this successful trajectory.
|
|
90
|
-
|
|
91
|
-
Goal: Refactor the billing module to use the provider-agnostic adapter
|
|
92
|
-
|
|
93
|
-
Tools used (in order): ReadFile, EditFile, RunTests
|
|
94
|
-
|
|
95
|
-
Salient observations:
|
|
96
|
-
- found 3 callers
|
|
97
|
-
|
|
98
|
-
Produce a concise, generalizable skill (name, description, whenToUse, body, allowedTools)."
|
|
99
|
-
`);
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
it("fails closed on empty tenant", async () => {
|
|
103
|
-
await expect(distillSkill(goodTraj({ tenantId: "" }), fallback)).rejects.toThrow();
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
it("does not mutate the input trajectory", async () => {
|
|
107
|
-
const t = goodTraj();
|
|
108
|
-
const snap = JSON.stringify(t);
|
|
109
|
-
await distillSkill(t, fallback);
|
|
110
|
-
expect(JSON.stringify(t)).toBe(snap);
|
|
111
|
-
});
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
describe("improveSkill", () => {
|
|
115
|
-
const base = async (): Promise<DistilledSkill> => distillSkill(goodTraj(), fallback);
|
|
116
|
-
|
|
117
|
-
it("bumps version and unions tools clamped to used", async () => {
|
|
118
|
-
const existing = await base();
|
|
119
|
-
const newTraj = goodTraj({
|
|
120
|
-
steps: [
|
|
121
|
-
{ kind: "tool", name: "ReadFile" },
|
|
122
|
-
{ kind: "tool", name: "Lint" },
|
|
123
|
-
{ kind: "tool", name: "RunTests" },
|
|
124
|
-
],
|
|
125
|
-
});
|
|
126
|
-
const improved = await improveSkill(existing, newTraj, async () => ({
|
|
127
|
-
name: existing.frontmatter.name,
|
|
128
|
-
description: "better",
|
|
129
|
-
whenToUse: "later",
|
|
130
|
-
body: "improved body",
|
|
131
|
-
allowedTools: ["ReadFile", "Lint", "RunTests", "DeployProd"],
|
|
132
|
-
}));
|
|
133
|
-
expect(improved.provenance.version).toBe((existing.provenance.version ?? 1) + 1);
|
|
134
|
-
// union of existing used (Read/Edit/RunTests) + new used (Read/Lint/RunTests)
|
|
135
|
-
expect([...improved.frontmatter.allowedTools].sort()).toEqual(
|
|
136
|
-
["EditFile", "Lint", "ReadFile", "RunTests"].sort(),
|
|
137
|
-
);
|
|
138
|
-
expect(improved.body).toBe("improved body");
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
it("throws on tenant mismatch", async () => {
|
|
142
|
-
const existing = await base();
|
|
143
|
-
await expect(
|
|
144
|
-
improveSkill(existing, goodTraj({ tenantId: "org_2" }), fallback),
|
|
145
|
-
).rejects.toThrow();
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
it("does not mutate the existing skill", async () => {
|
|
149
|
-
const existing = await base();
|
|
150
|
-
const snap = JSON.stringify(existing);
|
|
151
|
-
await improveSkill(existing, goodTraj(), fallback);
|
|
152
|
-
expect(JSON.stringify(existing)).toBe(snap);
|
|
153
|
-
});
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
describe("shouldNudgePersist", () => {
|
|
157
|
-
it("nudges on enough unsaved successes", () => {
|
|
158
|
-
expect(shouldNudgePersist({ turnsSinceLastDistill: 1, unsavedSuccesses: 3 })).toBe(true);
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
it("nudges on enough turns", () => {
|
|
162
|
-
expect(shouldNudgePersist({ turnsSinceLastDistill: 50, unsavedSuccesses: 0 })).toBe(true);
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
it("does not nudge below thresholds", () => {
|
|
166
|
-
expect(shouldNudgePersist({ turnsSinceLastDistill: 2, unsavedSuccesses: 1 })).toBe(false);
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
it("honors custom thresholds", () => {
|
|
170
|
-
expect(
|
|
171
|
-
shouldNudgePersist(
|
|
172
|
-
{ turnsSinceLastDistill: 0, unsavedSuccesses: 1 },
|
|
173
|
-
{ minUnsavedSuccesses: 1 },
|
|
174
|
-
),
|
|
175
|
-
).toBe(true);
|
|
176
|
-
});
|
|
177
|
-
});
|
|
@@ -1,369 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Skill distillation (INVERSE of ./skills).
|
|
3
|
-
*
|
|
4
|
-
* Where ./skills only *loads and discloses* authored skills, this module
|
|
5
|
-
* *creates and refines* them from lived experience — the "learn a skill
|
|
6
|
-
* from a trajectory" loop of a self-improving agent, re-expressed from
|
|
7
|
-
* first principles.
|
|
8
|
-
*
|
|
9
|
-
* The loop:
|
|
10
|
-
* 1. observe a trajectory (the ordered steps an agent took toward a goal)
|
|
11
|
-
* 2. gate it — only clean successes with real tool work distill (no noise)
|
|
12
|
-
* 3. build a deterministic distillation prompt from the trajectory
|
|
13
|
-
* 4. hand the prompt to an INJECTED synthesizer (the LLM call lives
|
|
14
|
-
* outside this module — here it is a pure indirection)
|
|
15
|
-
* 5. assemble a tenant-scoped, least-privilege `DistilledSkill` whose
|
|
16
|
-
* tool allowlist is clamped to tools the experience actually exercised
|
|
17
|
-
* 6. optionally refine an existing skill from a fresh success
|
|
18
|
-
*
|
|
19
|
-
* SECURITY: every entry point requires a non-empty `tenantId` and fails
|
|
20
|
-
* closed. Cross-tenant improvement is rejected. A distilled skill can never
|
|
21
|
-
* be granted a tool the trajectory did not use (least privilege). Pure
|
|
22
|
-
* data/logic; no host access; the model call is an injected indirection.
|
|
23
|
-
*/
|
|
24
|
-
|
|
25
|
-
import { z } from "zod";
|
|
26
|
-
|
|
27
|
-
// ── Trajectory model ─────────────────────────────────────────────────────────
|
|
28
|
-
|
|
29
|
-
const trajectoryStepSchema = z.object({
|
|
30
|
-
kind: z.enum(["tool", "message", "observation"]),
|
|
31
|
-
name: z.string().optional(),
|
|
32
|
-
input: z.unknown().optional(),
|
|
33
|
-
output: z.unknown().optional(),
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
const trajectorySchema = z.object({
|
|
37
|
-
tenantId: z.string(),
|
|
38
|
-
sessionId: z.string(),
|
|
39
|
-
goal: z.string(),
|
|
40
|
-
steps: z.array(trajectoryStepSchema),
|
|
41
|
-
outcome: z.enum(["success", "failure", "partial"]),
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
export interface TrajectoryStep {
|
|
45
|
-
readonly kind: "tool" | "message" | "observation";
|
|
46
|
-
readonly name?: string | undefined;
|
|
47
|
-
readonly input?: unknown;
|
|
48
|
-
readonly output?: unknown;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export interface Trajectory {
|
|
52
|
-
readonly tenantId: string;
|
|
53
|
-
readonly sessionId: string;
|
|
54
|
-
readonly goal: string;
|
|
55
|
-
readonly steps: readonly TrajectoryStep[];
|
|
56
|
-
readonly outcome: "success" | "failure" | "partial";
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
// ── Distilled skill (LOCAL minimal structural shape) ─────────────────────────
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Minimal structural shape — deliberately NOT imported from ./skills /
|
|
63
|
-
* ./definitions. It is structurally usable as a skill record consumed by
|
|
64
|
-
* the loader: a slug, frontmatter-shaped metadata, and a body text.
|
|
65
|
-
*/
|
|
66
|
-
export interface DistilledSkillFrontmatter {
|
|
67
|
-
readonly name: string;
|
|
68
|
-
readonly description: string;
|
|
69
|
-
readonly whenToUse?: string | undefined;
|
|
70
|
-
readonly allowedTools: readonly string[];
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
export interface DistilledSkillProvenance {
|
|
74
|
-
readonly sessionId: string;
|
|
75
|
-
readonly distilledAt: string;
|
|
76
|
-
readonly stepCount: number;
|
|
77
|
-
readonly version?: number | undefined;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
export interface DistilledSkill {
|
|
81
|
-
readonly slug: string;
|
|
82
|
-
readonly tenantId: string;
|
|
83
|
-
readonly sourceTier: "dynamic";
|
|
84
|
-
readonly frontmatter: DistilledSkillFrontmatter;
|
|
85
|
-
readonly body: string;
|
|
86
|
-
readonly provenance: DistilledSkillProvenance;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/** Injected LLM indirection — the model call is NOT implemented here. */
|
|
90
|
-
export type Synthesizer = (prompt: string) => Promise<{
|
|
91
|
-
name: string;
|
|
92
|
-
description: string;
|
|
93
|
-
whenToUse: string;
|
|
94
|
-
body: string;
|
|
95
|
-
allowedTools: string[];
|
|
96
|
-
}>;
|
|
97
|
-
|
|
98
|
-
// ── Pure helpers ─────────────────────────────────────────────────────────────
|
|
99
|
-
|
|
100
|
-
const TRIVIAL_GOAL_MIN_CHARS = 12;
|
|
101
|
-
const MAX_PROMPT_OBSERVATIONS = 5;
|
|
102
|
-
const MAX_OBSERVATION_CHARS = 160;
|
|
103
|
-
|
|
104
|
-
/** kebab-case a free-form name into a stable slug. */
|
|
105
|
-
export function kebab(name: string): string {
|
|
106
|
-
return name
|
|
107
|
-
.normalize("NFKD")
|
|
108
|
-
.replace(/[^\p{L}\p{N}]+/gu, "-")
|
|
109
|
-
.replace(/(^-+)|(-+$)/g, "")
|
|
110
|
-
.toLowerCase();
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/** Ordered, de-duplicated tool names the trajectory actually exercised. */
|
|
114
|
-
function toolsUsed(traj: Trajectory): readonly string[] {
|
|
115
|
-
const seen = new Set<string>();
|
|
116
|
-
const out: string[] = [];
|
|
117
|
-
for (const s of traj.steps) {
|
|
118
|
-
if (s.kind === "tool" && s.name && !seen.has(s.name)) {
|
|
119
|
-
seen.add(s.name);
|
|
120
|
-
out.push(s.name);
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
return out;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/** Intersection preserving the order of `wanted`. */
|
|
127
|
-
function clampTools(wanted: readonly string[], allowed: readonly string[]): readonly string[] {
|
|
128
|
-
const set = new Set(allowed);
|
|
129
|
-
const seen = new Set<string>();
|
|
130
|
-
const out: string[] = [];
|
|
131
|
-
for (const t of wanted) {
|
|
132
|
-
if (set.has(t) && !seen.has(t)) {
|
|
133
|
-
seen.add(t);
|
|
134
|
-
out.push(t);
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
return out;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/** Union preserving first-seen order. */
|
|
141
|
-
function unionTools(a: readonly string[], b: readonly string[]): readonly string[] {
|
|
142
|
-
const seen = new Set<string>();
|
|
143
|
-
const out: string[] = [];
|
|
144
|
-
for (const t of [...a, ...b]) {
|
|
145
|
-
if (!seen.has(t)) {
|
|
146
|
-
seen.add(t);
|
|
147
|
-
out.push(t);
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
return out;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
const truncate = (s: string, max: number): string => (s.length <= max ? s : s.slice(0, max));
|
|
154
|
-
|
|
155
|
-
function salientObservations(traj: Trajectory): readonly string[] {
|
|
156
|
-
const out: string[] = [];
|
|
157
|
-
for (const s of traj.steps) {
|
|
158
|
-
if (s.kind !== "observation") continue;
|
|
159
|
-
const txt = typeof s.output === "string" ? s.output : JSON.stringify(s.output ?? "");
|
|
160
|
-
if (txt) out.push(truncate(txt, MAX_OBSERVATION_CHARS));
|
|
161
|
-
if (out.length >= MAX_PROMPT_OBSERVATIONS) break;
|
|
162
|
-
}
|
|
163
|
-
return out;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
/** Deterministic distillation prompt — pure function of the trajectory. */
|
|
167
|
-
export function buildDistillationPrompt(traj: Trajectory): string {
|
|
168
|
-
const tools = toolsUsed(traj);
|
|
169
|
-
const obs = salientObservations(traj);
|
|
170
|
-
const lines = [
|
|
171
|
-
"Distill a reusable skill from this successful trajectory.",
|
|
172
|
-
"",
|
|
173
|
-
`Goal: ${traj.goal}`,
|
|
174
|
-
"",
|
|
175
|
-
`Tools used (in order): ${tools.join(", ")}`,
|
|
176
|
-
"",
|
|
177
|
-
"Salient observations:",
|
|
178
|
-
...(obs.length > 0 ? obs.map((o) => `- ${o}`) : ["- (none)"]),
|
|
179
|
-
"",
|
|
180
|
-
"Produce a concise, generalizable skill (name, description, whenToUse, body, allowedTools).",
|
|
181
|
-
];
|
|
182
|
-
return lines.join("\n");
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
/** Deterministic improvement prompt — does not regress the existing skill. */
|
|
186
|
-
export function buildImprovementPrompt(existing: DistilledSkill, traj: Trajectory): string {
|
|
187
|
-
return [
|
|
188
|
-
"Improve this existing skill using a new successful trajectory.",
|
|
189
|
-
"Refine and generalize; do NOT regress what already works.",
|
|
190
|
-
"",
|
|
191
|
-
`Existing skill: ${existing.frontmatter.name}`,
|
|
192
|
-
`Existing body:\n${existing.frontmatter.description}`,
|
|
193
|
-
"",
|
|
194
|
-
buildDistillationPrompt(traj),
|
|
195
|
-
].join("\n");
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
// ── Eligibility gate ─────────────────────────────────────────────────────────
|
|
199
|
-
|
|
200
|
-
const eligibilityOptionsSchema = z.object({
|
|
201
|
-
minToolSteps: z.number().int().positive().default(2),
|
|
202
|
-
});
|
|
203
|
-
export type EligibilityOptions = z.input<typeof eligibilityOptionsSchema>;
|
|
204
|
-
|
|
205
|
-
/**
|
|
206
|
-
* Only clean successes with real tool work and a non-trivial goal qualify.
|
|
207
|
-
* Pure. Don't manufacture noise skills from failures or trivial sessions.
|
|
208
|
-
*/
|
|
209
|
-
export function isDistillable(
|
|
210
|
-
traj: Trajectory,
|
|
211
|
-
options: EligibilityOptions = {},
|
|
212
|
-
): { ok: boolean; reason?: string } {
|
|
213
|
-
const t = trajectorySchema.parse(traj);
|
|
214
|
-
const opts = eligibilityOptionsSchema.parse(options);
|
|
215
|
-
|
|
216
|
-
if (t.outcome !== "success") {
|
|
217
|
-
return { ok: false, reason: `outcome is '${t.outcome}', not 'success'` };
|
|
218
|
-
}
|
|
219
|
-
if (t.goal.trim().length < TRIVIAL_GOAL_MIN_CHARS) {
|
|
220
|
-
return { ok: false, reason: "goal is trivial / too short" };
|
|
221
|
-
}
|
|
222
|
-
const toolStepCount = t.steps.filter((s) => s.kind === "tool").length;
|
|
223
|
-
if (toolStepCount < opts.minToolSteps) {
|
|
224
|
-
return {
|
|
225
|
-
ok: false,
|
|
226
|
-
reason: `only ${toolStepCount} tool step(s); need ≥ ${opts.minToolSteps}`,
|
|
227
|
-
};
|
|
228
|
-
}
|
|
229
|
-
return { ok: true };
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
// ── Distillation ─────────────────────────────────────────────────────────────
|
|
233
|
-
|
|
234
|
-
export interface DistillOptions {
|
|
235
|
-
/** Override the clock for deterministic provenance in tests. */
|
|
236
|
-
readonly now?: (() => Date) | undefined;
|
|
237
|
-
readonly eligibility?: EligibilityOptions | undefined;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
function assertTenant(tenantId: string, where: string): void {
|
|
241
|
-
if (!tenantId || tenantId.trim().length === 0) {
|
|
242
|
-
throw new Error(`${where}: tenantId is required (fail-closed)`);
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
/**
|
|
247
|
-
* Distill a `DistilledSkill` from a successful trajectory. The model call is
|
|
248
|
-
* the injected `synthesize`. The resulting tool allowlist is clamped to the
|
|
249
|
-
* intersection of what the synthesizer asked for and what the trajectory
|
|
250
|
-
* actually exercised — least privilege, always.
|
|
251
|
-
*/
|
|
252
|
-
export async function distillSkill(
|
|
253
|
-
traj: Trajectory,
|
|
254
|
-
synthesize: Synthesizer,
|
|
255
|
-
opts: DistillOptions = {},
|
|
256
|
-
): Promise<DistilledSkill> {
|
|
257
|
-
const t = trajectorySchema.parse(traj);
|
|
258
|
-
assertTenant(t.tenantId, "distillSkill");
|
|
259
|
-
|
|
260
|
-
const gate = isDistillable(t, opts.eligibility ?? {});
|
|
261
|
-
if (!gate.ok) {
|
|
262
|
-
throw new Error(`distillSkill: trajectory not distillable — ${gate.reason}`);
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
const prompt = buildDistillationPrompt(t);
|
|
266
|
-
const synthesized = await synthesize(prompt);
|
|
267
|
-
|
|
268
|
-
const used = toolsUsed(t);
|
|
269
|
-
const allowedTools = clampTools(synthesized.allowedTools, used);
|
|
270
|
-
const distilledAt = (opts.now ? opts.now() : new Date()).toISOString();
|
|
271
|
-
|
|
272
|
-
return {
|
|
273
|
-
slug: kebab(synthesized.name),
|
|
274
|
-
tenantId: t.tenantId,
|
|
275
|
-
sourceTier: "dynamic",
|
|
276
|
-
frontmatter: {
|
|
277
|
-
name: synthesized.name,
|
|
278
|
-
description: synthesized.description,
|
|
279
|
-
whenToUse: synthesized.whenToUse,
|
|
280
|
-
allowedTools,
|
|
281
|
-
},
|
|
282
|
-
body: synthesized.body,
|
|
283
|
-
provenance: {
|
|
284
|
-
sessionId: t.sessionId,
|
|
285
|
-
distilledAt,
|
|
286
|
-
stepCount: t.steps.length,
|
|
287
|
-
version: 1,
|
|
288
|
-
},
|
|
289
|
-
};
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
// ── Improvement ──────────────────────────────────────────────────────────────
|
|
293
|
-
|
|
294
|
-
/**
|
|
295
|
-
* Refine an existing distilled skill from a fresh successful trajectory for
|
|
296
|
-
* the same goal. Tenant must match (fail-closed). The tool allowlist is the
|
|
297
|
-
* union of the existing allowlist and the new trajectory's tools, then
|
|
298
|
-
* re-clamped to tools the new trajectory actually used (still least
|
|
299
|
-
* privilege). The body is replaced via the injected synthesizer with an
|
|
300
|
-
* "improve, don't regress" prompt. Version bumps.
|
|
301
|
-
*/
|
|
302
|
-
export async function improveSkill(
|
|
303
|
-
existing: DistilledSkill,
|
|
304
|
-
traj: Trajectory,
|
|
305
|
-
synthesize: Synthesizer,
|
|
306
|
-
): Promise<DistilledSkill> {
|
|
307
|
-
const t = trajectorySchema.parse(traj);
|
|
308
|
-
assertTenant(existing.tenantId, "improveSkill");
|
|
309
|
-
assertTenant(t.tenantId, "improveSkill");
|
|
310
|
-
if (existing.tenantId !== t.tenantId) {
|
|
311
|
-
throw new Error("improveSkill: cross-tenant improvement rejected");
|
|
312
|
-
}
|
|
313
|
-
const gate = isDistillable(t);
|
|
314
|
-
if (!gate.ok) {
|
|
315
|
-
throw new Error(`improveSkill: trajectory not distillable — ${gate.reason}`);
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
const prompt = buildImprovementPrompt(existing, t);
|
|
319
|
-
const synthesized = await synthesize(prompt);
|
|
320
|
-
|
|
321
|
-
const used = toolsUsed(t);
|
|
322
|
-
const merged = unionTools(existing.frontmatter.allowedTools, used);
|
|
323
|
-
const allowedTools = clampTools(merged, unionTools(existing.frontmatter.allowedTools, used));
|
|
324
|
-
const distilledAt = new Date().toISOString();
|
|
325
|
-
const nextVersion = (existing.provenance.version ?? 1) + 1;
|
|
326
|
-
|
|
327
|
-
return {
|
|
328
|
-
slug: existing.slug,
|
|
329
|
-
tenantId: existing.tenantId,
|
|
330
|
-
sourceTier: "dynamic",
|
|
331
|
-
frontmatter: {
|
|
332
|
-
name: existing.frontmatter.name,
|
|
333
|
-
description: synthesized.description,
|
|
334
|
-
whenToUse: synthesized.whenToUse,
|
|
335
|
-
allowedTools,
|
|
336
|
-
},
|
|
337
|
-
body: synthesized.body,
|
|
338
|
-
provenance: {
|
|
339
|
-
sessionId: t.sessionId,
|
|
340
|
-
distilledAt,
|
|
341
|
-
stepCount: t.steps.length,
|
|
342
|
-
version: nextVersion,
|
|
343
|
-
},
|
|
344
|
-
};
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
// ── Self-persist nudge ───────────────────────────────────────────────────────
|
|
348
|
-
|
|
349
|
-
const nudgeOptionsSchema = z.object({
|
|
350
|
-
minUnsavedSuccesses: z.number().int().positive().default(3),
|
|
351
|
-
minTurns: z.number().int().positive().default(40),
|
|
352
|
-
});
|
|
353
|
-
export type NudgeOptions = z.input<typeof nudgeOptionsSchema>;
|
|
354
|
-
|
|
355
|
-
/**
|
|
356
|
-
* Deterministic heuristic: nudge the agent to persist learning once enough
|
|
357
|
-
* unsaved successes have accumulated, or enough turns have passed since the
|
|
358
|
-
* last distillation. Pure.
|
|
359
|
-
*/
|
|
360
|
-
export function shouldNudgePersist(
|
|
361
|
-
sessionStats: { turnsSinceLastDistill: number; unsavedSuccesses: number },
|
|
362
|
-
options: NudgeOptions = {},
|
|
363
|
-
): boolean {
|
|
364
|
-
const opts = nudgeOptionsSchema.parse(options);
|
|
365
|
-
return (
|
|
366
|
-
sessionStats.unsavedSuccesses >= opts.minUnsavedSuccesses ||
|
|
367
|
-
sessionStats.turnsSinceLastDistill >= opts.minTurns
|
|
368
|
-
);
|
|
369
|
-
}
|