@letta-ai/letta-code 0.27.18 → 0.27.20

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@letta-ai/letta-code",
3
- "version": "0.27.18",
3
+ "version": "0.27.20",
4
4
  "description": "Letta Code is a CLI tool for interacting with stateful Letta agents from the terminal.",
5
5
  "type": "module",
6
6
  "packageManager": "bun@1.3.0",
@@ -47,6 +47,7 @@
47
47
  "@earendil-works/pi-ai": "^0.79.6",
48
48
  "@letta-ai/letta-client": "^1.10.2",
49
49
  "@pierre/diffs": "1.2.2",
50
+ "@scarf/scarf": "^1.4.0",
50
51
  "glob": "^13.0.0",
51
52
  "ink-link": "^5.0.0",
52
53
  "node-pty": "^1.1.0",
@@ -97,7 +98,8 @@
97
98
  "test:update-chain:manual": "bun run src/test-utils/update-chain-smoke.ts --mode manual",
98
99
  "test:update-chain:startup": "bun run src/test-utils/update-chain-smoke.ts --mode startup",
99
100
  "prepublishOnly": "bun run build",
100
- "postinstall": "node scripts/postinstall-patches.js || echo letta: vendor patches skipped && node -e \"try{require('fs').chmodSync(require('path').join(require.resolve('node-pty/package.json'),'../prebuilds/darwin-arm64/spawn-helper'),0o755)}catch(e){}\" || true"
101
+ "postinstall": "node scripts/postinstall-patches.js || echo letta: vendor patches skipped && node -e \"try{require('fs').chmodSync(require('path').join(require.resolve('node-pty/package.json'),'../prebuilds/darwin-arm64/spawn-helper'),0o755)}catch(e){}\" || true",
102
+ "mod-learning:memory-citations": "bun scripts/mod-learning/learn-mod.ts --env docs/examples/mods/learning/memory-citations.env.json"
101
103
  },
102
104
  "lint-staged": {
103
105
  "*.{ts,tsx,js,jsx,json}": [
@@ -0,0 +1,306 @@
1
+ #!/usr/bin/env bun
2
+ import { spawn } from "node:child_process";
3
+ import { closeSync, openSync } from "node:fs";
4
+ import { mkdir, writeFile } from "node:fs/promises";
5
+ import path from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import {
8
+ defaultModLearningRunDirectory,
9
+ type ModLearningProgress,
10
+ type ModLearningReport,
11
+ readModLearningEnv,
12
+ runModLearning,
13
+ } from "../../src/mods/learning-harness.ts";
14
+
15
+ const DEFAULT_OPTIMIZATION_STEPS = 5;
16
+
17
+ interface Args {
18
+ backend?: string;
19
+ candidate?: string;
20
+ candidateCount?: number;
21
+ candidateFileName?: string;
22
+ evalModel?: string;
23
+ generationModel?: string;
24
+ help: boolean;
25
+ env: string;
26
+ foreground: boolean;
27
+ out?: string;
28
+ promoteTo?: string;
29
+ repoRoot: string;
30
+ scenarioLimit?: number;
31
+ skipGeneration: boolean;
32
+ }
33
+
34
+ function readOptionValue(
35
+ argv: string[],
36
+ index: number,
37
+ optionName: string,
38
+ ): { nextIndex: number; value: string } {
39
+ const arg = argv[index] ?? "";
40
+ const equalsPrefix = `${optionName}=`;
41
+ if (arg.startsWith(equalsPrefix)) {
42
+ const value = arg.slice(equalsPrefix.length);
43
+ if (!value) throw new Error(`${optionName} requires a value`);
44
+ return { nextIndex: index, value };
45
+ }
46
+
47
+ const value = argv[index + 1];
48
+ if (!value || value.startsWith("--")) {
49
+ throw new Error(`${optionName} requires a value`);
50
+ }
51
+ return { nextIndex: index + 1, value };
52
+ }
53
+
54
+ function parseArgs(argv: string[]): Args {
55
+ const args: Args = {
56
+ env: "docs/examples/mods/learning/memory-citations.env.json",
57
+ foreground: false,
58
+ help: false,
59
+ repoRoot: process.cwd(),
60
+ skipGeneration: false,
61
+ };
62
+
63
+ for (let index = 0; index < argv.length; index += 1) {
64
+ const arg = argv[index];
65
+ if (arg === "--help" || arg === "-h") {
66
+ args.help = true;
67
+ } else if (arg === "--background") {
68
+ args.foreground = false;
69
+ } else if (arg === "--foreground") {
70
+ args.foreground = true;
71
+ } else if (arg === "--skip-generation") {
72
+ args.skipGeneration = true;
73
+ } else if (arg?.startsWith("--")) {
74
+ const optionName = arg.includes("=")
75
+ ? arg.slice(0, arg.indexOf("="))
76
+ : arg;
77
+ const { nextIndex, value } = readOptionValue(argv, index, optionName);
78
+ index = nextIndex;
79
+ switch (optionName) {
80
+ case "--backend":
81
+ args.backend = value;
82
+ break;
83
+ case "--candidate":
84
+ args.candidate = value;
85
+ break;
86
+ case "--candidates":
87
+ args.candidateCount = Number(value);
88
+ break;
89
+ case "--candidate-file-name":
90
+ args.candidateFileName = value;
91
+ break;
92
+ case "--eval-model":
93
+ args.evalModel = value;
94
+ break;
95
+ case "--env":
96
+ args.env = value;
97
+ break;
98
+ case "--generation-model":
99
+ args.generationModel = value;
100
+ break;
101
+ case "--model":
102
+ args.generationModel = value;
103
+ args.evalModel = value;
104
+ break;
105
+ case "--out":
106
+ args.out = value;
107
+ break;
108
+ case "--promote-to":
109
+ args.promoteTo = value;
110
+ break;
111
+ case "--repo-root":
112
+ args.repoRoot = value;
113
+ break;
114
+ case "--scenario-limit":
115
+ args.scenarioLimit = Number(value);
116
+ break;
117
+ default:
118
+ throw new Error(`Unknown argument: ${arg}`);
119
+ }
120
+ } else {
121
+ throw new Error(`Unknown argument: ${arg}`);
122
+ }
123
+ }
124
+
125
+ return args;
126
+ }
127
+
128
+ function printHelp(): void {
129
+ console.log(`Usage: bun scripts/mod-learning/learn-mod.ts [options]
130
+
131
+ Runs the mod learning dogfood loop:
132
+ env/demo -> generate candidate mod -> headless eval with LETTA_MODS_DIR (and legacy LETTA_EXTENSIONS_DIR for pre-rename branches) -> artifacts/report
133
+
134
+ Options:
135
+ --env <path> Learning env JSON (default: memory-citations env)
136
+ --out <dir> Run artifact directory (default: .letta/mod-learning-runs/<slug>-<timestamp>)
137
+ --candidate <path> Use an existing candidate mod instead of generation
138
+ --candidates <n> Run N optimization iterations for one learned mod (default: 5 for generated runs)
139
+ --candidate-file-name <name> Candidate filename inside the eval mod directory
140
+ --model <handle> Model for generation and eval
141
+ --generation-model <handle> Model for candidate generation
142
+ --eval-model <handle> Model for headless eval
143
+ --backend <mode> Backend flag forwarded to letta (api or local)
144
+ --scenario-limit <n> Evaluate only the first N scenarios (fast smoke testing)
145
+ --repo-root <path> Repo root (default: cwd)
146
+ --foreground Run learning in this process and return a pass/fail exit code
147
+ --background Explicitly use the default detached mode
148
+ --skip-generation Expect the candidate file to already exist in the run dir
149
+ --promote-to <path> Copy passing candidate to this repo-relative path
150
+ -h, --help Show this help
151
+ `);
152
+ }
153
+
154
+ async function launchBackground(params: {
155
+ argv: string[];
156
+ repoRoot: string;
157
+ runDir: string;
158
+ outWasProvided: boolean;
159
+ }): Promise<void> {
160
+ await mkdir(params.runDir, { recursive: true });
161
+ const stdoutPath = path.join(params.runDir, "background.stdout");
162
+ const stderrPath = path.join(params.runDir, "background.stderr");
163
+ const metadataPath = path.join(params.runDir, "background.json");
164
+ const childArgv = params.argv.filter(
165
+ (arg) => arg !== "--background" && arg !== "--foreground",
166
+ );
167
+ childArgv.push("--foreground");
168
+ if (!params.outWasProvided) childArgv.push("--out", params.runDir);
169
+
170
+ const stdoutFd = openSync(stdoutPath, "a");
171
+ const stderrFd = openSync(stderrPath, "a");
172
+ let childPid: number | undefined;
173
+ try {
174
+ const child = spawn(
175
+ process.execPath,
176
+ [fileURLToPath(import.meta.url), ...childArgv],
177
+ {
178
+ cwd: params.repoRoot,
179
+ detached: true,
180
+ env: process.env,
181
+ stdio: ["ignore", stdoutFd, stderrFd],
182
+ },
183
+ );
184
+ child.unref();
185
+ childPid = child.pid;
186
+ await writeFile(
187
+ metadataPath,
188
+ `${JSON.stringify(
189
+ {
190
+ args: childArgv,
191
+ command: process.execPath,
192
+ pid: childPid,
193
+ runDir: params.runDir,
194
+ startedAt: new Date().toISOString(),
195
+ stderrPath,
196
+ stdoutPath,
197
+ },
198
+ null,
199
+ 2,
200
+ )}\n`,
201
+ "utf8",
202
+ );
203
+ } finally {
204
+ closeSync(stdoutFd);
205
+ closeSync(stderrFd);
206
+ }
207
+
208
+ console.log(`BACKGROUND ${params.runDir}`);
209
+ console.log(`PID ${childPid ?? "unknown"}`);
210
+ console.log(`stdout ${stdoutPath}`);
211
+ console.log(`stderr ${stderrPath}`);
212
+ console.log(`progress tail -f ${stdoutPath}`);
213
+ console.log(`report ${path.join(params.runDir, "report.md")}`);
214
+ }
215
+
216
+ function formatScore(
217
+ score: number | undefined,
218
+ maxScore: number | undefined,
219
+ ): string {
220
+ if (score === undefined) return "";
221
+ if (maxScore === undefined) return ` score ${score}`;
222
+ const percentage = maxScore > 0 ? Math.round((score / maxScore) * 100) : 0;
223
+ return ` score ${score}/${maxScore} (${percentage}%)`;
224
+ }
225
+
226
+ function progressPrefix(progress: ModLearningProgress): string {
227
+ if (progress.candidateIndex && progress.candidateCount) {
228
+ return `[${progress.candidateIndex}/${progress.candidateCount}]`;
229
+ }
230
+ if (progress.candidateIndex) return `[${progress.candidateIndex}]`;
231
+ return `[${progress.phase}]`;
232
+ }
233
+
234
+ function formatProgressLine(progress: ModLearningProgress): string {
235
+ return `${progressPrefix(progress)} ${progress.message}${formatScore(progress.score, progress.maxScore)}`;
236
+ }
237
+
238
+ function candidateForPromote(report: ModLearningReport): string {
239
+ return report.candidatePath;
240
+ }
241
+
242
+ async function main(): Promise<void> {
243
+ const argv = process.argv.slice(2);
244
+ const args = parseArgs(argv);
245
+ if (args.help) {
246
+ printHelp();
247
+ return;
248
+ }
249
+
250
+ const repoRoot = path.resolve(args.repoRoot);
251
+ const learningEnv = await readModLearningEnv(
252
+ path.resolve(repoRoot, args.env),
253
+ );
254
+ const runDir = args.out
255
+ ? path.resolve(repoRoot, args.out)
256
+ : path.resolve(repoRoot, defaultModLearningRunDirectory(learningEnv));
257
+
258
+ if (!args.foreground) {
259
+ await launchBackground({
260
+ argv,
261
+ outWasProvided: args.out !== undefined,
262
+ repoRoot,
263
+ runDir,
264
+ });
265
+ return;
266
+ }
267
+
268
+ let lastProgressLine = "";
269
+ const report = await runModLearning({
270
+ backend: args.backend,
271
+ candidateCount:
272
+ args.candidateCount ??
273
+ (args.candidate || args.skipGeneration
274
+ ? undefined
275
+ : DEFAULT_OPTIMIZATION_STEPS),
276
+ candidateFileName: args.candidateFileName,
277
+ candidateSourcePath: args.candidate,
278
+ evalModel: args.evalModel,
279
+ generationModel: args.generationModel,
280
+ promoteToPath: args.promoteTo,
281
+ repoRoot,
282
+ runDir,
283
+ scenarioLimit: args.scenarioLimit,
284
+ skipGeneration: args.skipGeneration,
285
+ spec: learningEnv,
286
+ onProgress: (progress) => {
287
+ const line = formatProgressLine(progress);
288
+ if (line === lastProgressLine) return;
289
+ lastProgressLine = line;
290
+ console.log(line);
291
+ },
292
+ });
293
+
294
+ const status = report.passed ? "PASS" : "FAIL";
295
+ console.log(`${status} ${report.reportPath}`);
296
+ console.log(`candidate ${candidateForPromote(report)}`);
297
+ if (report.passed) {
298
+ console.log(`promote letta mods promote ${candidateForPromote(report)}`);
299
+ }
300
+ if (!report.passed) process.exit(1);
301
+ }
302
+
303
+ main().catch((error) => {
304
+ console.error(error instanceof Error ? error.stack : String(error));
305
+ process.exit(1);
306
+ });
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: Context Doctor
3
- id: context_doctor
3
+ id: context-doctor
4
4
  description: Identify and repair degradation in system prompt, external memory, and skills preventing you from following instructions or remembering information as well as you should.
5
5
  ---
6
6
 
@@ -7,7 +7,7 @@ description: Creates and edits trusted local Letta Code mods, including tools, s
7
7
 
8
8
  Use this skill to create or update trusted Letta Code mod files. Mods are trusted local code that add small composable capabilities through mod APIs, not by importing app internals. Dynamic agent/conversation/workspace/model state is passed as `ctx` to tool, command, event, and permission callbacks (panels receive live `agent`/`model` in their render context); do not read mutable global context for model-callable behavior. Prefer scoped handles (`ctx.conversation`, `ctx.cwd`, `ctx.agent`) and guard optional UI with `letta.capabilities`.
9
9
 
10
- Capabilities vary by surface. TUI/headless may load tools, commands, events, UI, and providers; the desktop listener loads provider-only mods for local provider discovery. Always guard optional capabilities.
10
+ Capabilities vary by surface — not every surface loads every capability. The TUI/headless host can load tools, commands, events, UI, and providers; the desktop listener loads tools, commands, providers, and tool/turn events, but not panel UI. Always guard each registration on the capabilities its behavior needs.
11
11
 
12
12
  ## Choose where the mod file lives
13
13
 
@@ -93,6 +93,8 @@ letta.capabilities.providers
93
93
  letta.capabilities.ui.panels
94
94
  ```
95
95
 
96
+ Guard each registration on every capability its behavior depends on — not just the one that registers it. Surfaces load different capability subsets, so a registration that relies on another capability (a command that opens UI, emits an event, or calls a provider) must guard on that capability too. Otherwise it is advertised or activated on a host that cannot fulfill it and silently does nothing. Register where the host can actually do the work.
97
+
96
98
  ## Scoped API model
97
99
 
98
100
  - In commands and events, use `ctx.conversation` for conversation operations:
@@ -136,6 +138,7 @@ Before finishing, verify:
136
138
  - Tool descriptions explain when the model should call them.
137
139
  - JSON schemas are object schemas with useful descriptions.
138
140
  - Optional UI/event APIs are capability-guarded.
141
+ - Each registration is guarded by every capability its behavior depends on, not just the one that registers it, so it isn't advertised or activated on a surface that can't fulfill it.
139
142
  - Provider mods are capability-guarded and clearly documented as local-agent only.
140
143
  - Timers, intervals, event registrations, and panels are cleaned up in a disposer.
141
144
  - Busy commands return `{ type: "handled" }` quickly and avoid main-conversation sends.
@@ -59,7 +59,7 @@ letta.events.on("tool_start", (event, ctx) => {
59
59
 
60
60
  Lifecycle, turn, tool, compaction, and llm events are wired today.
61
61
 
62
- Lifecycle handlers are notification-only and should not return values. `turn_start` handlers can transform the outbound input for the next model turn. `tool_start` handlers can transform the tool arguments before execution. Compaction and llm handlers are notification-only.
62
+ Lifecycle handlers are notification-only and should not return values. `turn_start` handlers can transform or cancel outbound user-message turns. `tool_start` handlers can transform the tool arguments before execution. Compaction and llm handlers are notification-only.
63
63
 
64
64
  `compact_start`/`compact_end` and `llm_start`/`llm_end` only fire on the **local backend**, where compaction and provider requests run client-side. On the constellation backend that work happens server-side and these events do not fire, so guard with `letta.capabilities.events.compact` / `letta.capabilities.events.llm` for portable mods.
65
65
 
@@ -155,16 +155,18 @@ Handlers run in registration order. Later handlers see the current args after ea
155
155
  conversationId: string | null;
156
156
  toolCallId: string | null;
157
157
  toolName: string;
158
+ args: Record<string, unknown>;
158
159
  status: "success" | "error";
159
160
  output: string;
160
161
  }
161
162
  ```
162
163
 
163
- `tool_end` fires immediately after a tool produces a result, before the agent sees it. Handlers can inspect the result, or return `{ result: { status, output } }` to replace it:
164
+ `tool_end` fires immediately after a tool produces a result, before the agent sees it. `event.args` contains the effective tool invocation arguments after `tool_start` transforms, so handlers can react to the specific file, command, query, etc. Handlers can inspect the result, or return `{ result: { status, output } }` to replace it:
164
165
 
165
166
  ```ts
166
167
  letta.events.on("tool_end", (event) => {
167
168
  if (event.toolName !== "Bash" || event.status !== "success") return;
169
+ if (typeof event.args.command !== "string") return;
168
170
  return { result: { status: "success", output: redactSecrets(event.output) } };
169
171
  });
170
172
  ```
@@ -184,7 +186,7 @@ letta.events.on("tool_end", async (event, ctx) => {
184
186
  });
185
187
  ```
186
188
 
187
- `turn_start` fires before outbound turns that include a user message. In the TUI this includes normal submits and prompt-style command turns. In headless it includes one-shot prompts and bidirectional user turns.
189
+ `turn_start` fires before outbound turns that include a user message. In the TUI this includes normal submits and prompt-style command turns. In headless it includes one-shot prompts and bidirectional user turns. Listener/Desktop skips approval-only continuations so mods do not rewrite approval payloads; do not rely on `turn_start` to block approval-only continuations on every surface.
188
190
 
189
191
  Handlers can mutate `event.input` directly or return replacement input:
190
192
 
@@ -212,6 +214,18 @@ letta.events.on("turn_start", (event) => {
212
214
  });
213
215
  ```
214
216
 
217
+ Handlers can also cancel a user-message turn before it reaches the backend/model:
218
+
219
+ ```ts
220
+ letta.events.on("turn_start", (event) => {
221
+ if (!isPlanModeActive(event.conversationId)) {
222
+ return { cancel: { reason: "Run /plan first." } };
223
+ }
224
+ });
225
+ ```
226
+
227
+ If multiple handlers cancel, the first valid cancel reason wins. A valid reason is a non-empty string after trimming. Cancellation does not synthesize an assistant response or tool result; it only tells the host not to submit this turn.
228
+
215
229
  Handlers run in registration order. Later handlers see the current input after earlier mutations/returns. If a handler throws, its partial `event.input` mutation is rolled back and the error is recorded as a mod diagnostic.
216
230
 
217
231
  `turn_start` is intentionally a trusted local mod point: it can rewrite user messages, approval results, and ordering. Keep transforms focused and unsurprising.
@@ -270,12 +284,18 @@ Handlers run in registration order. Later handlers see the current input after e
270
284
  promptTokens: number;
271
285
  completionTokens: number;
272
286
  totalTokens: number;
273
- };
287
+ } | null;
274
288
  durationMs: number;
289
+ error?: {
290
+ message: string;
291
+ detail: string;
292
+ errorType: "llm_error" | "local_backend_error";
293
+ retryable: boolean;
294
+ };
275
295
  }
276
296
  ```
277
297
 
278
- `llm_end` fires once a provider request produces a final message, carrying the stop reason, token usage, and wall-clock duration. A request that fails before producing a final message (e.g. a transport error that triggers a retry) emits no `llm_end`. Both events are notification-only; return values are ignored. A throwing handler is isolated and never breaks the provider request.
298
+ `llm_end` fires when a provider request ends, success or failure. Successful requests include token usage. Requests that fail before usage is available set `usage: null` and include `error`. Retry/failover effects are not supported yet; both events are notification-only and return values are ignored. A throwing handler is isolated and never breaks the provider request.
279
299
 
280
300
  Handlers also receive:
281
301
 
@@ -60,6 +60,29 @@ render(ctx: {
60
60
 
61
61
  Close panels when they are transient, and close/replace long-lived panels from the activation disposer if reload should remove them.
62
62
 
63
+ ### Commands that open panels
64
+
65
+ If a command's `run()` opens a panel, guard the command **registration** on `letta.capabilities.ui.panels` — not just the `openPanel` call:
66
+
67
+ ```ts
68
+ export default function activate(letta) {
69
+ if (!letta.capabilities.commands) return;
70
+ if (!letta.capabilities.ui.panels) return; // panel-only command: skip where panels are unsupported
71
+
72
+ return letta.commands.register({
73
+ id: "mycommand",
74
+ description: "…",
75
+ run() {
76
+ const panel = letta.ui.openPanel({ /* … */ });
77
+ // …
78
+ return { type: "handled" };
79
+ },
80
+ });
81
+ }
82
+ ```
83
+
84
+ Guarding only the `openPanel` call is not enough. The desktop listener has `commands: true` but `ui.panels: false`, so a command registered there is advertised in the command picker while its panel work no-ops — the user sees a command that runs and does nothing. Gating the registration keeps panel-only commands out of hosts that can't render them.
85
+
63
86
  ## Timers and cleanup
64
87
 
65
88
  ```ts
@@ -0,0 +1,130 @@
1
+ ---
2
+ name: generating-mod-envs
3
+ description: Generates and reviews mod learning env JSON files for Letta Code local mods. Use when asked to teach, learn, or optimize a mod behavior; create, draft, validate, improve, or explain envs for `/mods learn --env`; or design evaluation scenarios, memory fixtures, requiredResultMarkers, requiredTraceMarkers, negative controls, and candidate diversity hints.
4
+ disable-model-invocation: true
5
+ user-invocable: true
6
+ ---
7
+
8
+ # Generating mod learning envs
9
+
10
+ Use this skill to create JSON envs consumed by `/mods learn --env=<path>` or `bun scripts/mod-learning/learn-mod.ts --env <path>`. An env describes the mod behavior to learn and the scenario-suite eval used to score candidates.
11
+
12
+ ## Workflow
13
+
14
+ 1. Define the behavior and eval before writing JSON.
15
+ - What should the mod do? Tool, turn event, tool event, provider, command, status, etc.
16
+ - What would a placebo/no-op mod fail?
17
+ - What unique sentinel strings make success unambiguous?
18
+ 2. Choose a path:
19
+ - Repo example: `docs/examples/mods/learning/<slug>.env.json`
20
+ - Local/private: any user-requested path
21
+ 3. Draft strict JSON. Start from `assets/mod-learning-env.template.json` if useful. No comments or trailing commas.
22
+ 4. Prefer `evaluation.scenarios` with at least:
23
+ - happy path
24
+ - discrimination/exact-target path
25
+ - negative control
26
+ 5. Validate:
27
+
28
+ ```bash
29
+ bun src/skills/builtin/generating-mod-envs/scripts/validate-mod-env.ts path/to/env.json
30
+ ```
31
+
32
+ If this skill is installed outside the source tree, run the same script from this skill directory: `scripts/validate-mod-env.ts`.
33
+
34
+ 6. If asked to run it:
35
+
36
+ ```text
37
+ /mods learn --env=path/to/env.json --model=auto --backend=api --out=/tmp/<slug>-learn
38
+ ```
39
+
40
+ The raw `scripts/mod-learning/learn-mod.ts` dev script detaches by default. Add `--foreground` only when a blocking pass/fail exit code is needed.
41
+
42
+ Use single-line `--flag=value` commands for TUI instructions.
43
+
44
+ ## Env shape
45
+
46
+ Required top-level fields:
47
+
48
+ - `name`: human display name.
49
+ - `slug`: stable kebab-case run/candidate slug.
50
+ - `objective`: one-paragraph target for the generation agent.
51
+ - `requirements`: concrete pass/fail behavior constraints.
52
+ - `evaluation`: either a single prompt eval or a scenario suite.
53
+
54
+ Common optional fields:
55
+
56
+ - `targetModName`: display metadata for the intended mod filename. The harness still chooses the candidate filename from `slug` unless `--candidate-file-name` is passed.
57
+ - `candidateDiversityHints`: strategies assigned across multi-candidate runs.
58
+ - `modApiHints`: concise API reminders that prevent bad generated code.
59
+ - `examples`: small input/expected demos for the generation prompt.
60
+
61
+ Evaluation fields:
62
+
63
+ - `evaluation.outputFormat`: use `stream-json` when checking trace markers.
64
+ - `evaluation.timeoutMs`, `evaluation.maxTurns`: per-scenario defaults.
65
+ - `evaluation.memoryFiles`: files seeded under eval `$MEMORY_DIR`.
66
+ - `evaluation.scenarios[]`: scenario-specific overrides and fixtures.
67
+ - In scenario-suite envs, do not add a top-level `evaluation.prompt` unless that prompt must run for every scenario. Assertion-only scenarios should have `assertions` and no `prompt`; only scenarios that require model behavior should define `scenario.prompt`.
68
+ - `requiredResultMarkers`: literal strings required in the final answer.
69
+ - `requiredTraceMarkers`: literal strings required in raw stdout/stderr.
70
+ - `forbiddenResultMarkers`: final-answer strings that fail the run.
71
+ - `forbiddenTraceMarkers`: raw trace strings that fail the run.
72
+
73
+ ## Quality rules
74
+
75
+ - Design the eval first. A useful env distinguishes success from a no-op mod.
76
+ - Use unique sentinels, e.g. `MY-MOD-CANARY-OK`, not common phrases.
77
+ - Seed `memoryFiles` rather than depending on real user memory or repo files.
78
+ - Include negative controls for non-use. If behavior should be conditional, verify it stays silent when not triggered.
79
+ - Include discrimination scenarios when paths, IDs, or sources matter. Put a tempting wrong sentinel in an irrelevant fixture and forbid it in the final answer.
80
+ - Put load failures in `forbiddenTraceMarkers`, usually:
81
+ - `[mods] failed to load`
82
+ - `[extensions] failed to load`
83
+ - `loaded 0 mod(s)`
84
+ - `loaded 0 extension(s)`
85
+ - For eval-facing tools, require `requiresApproval: false`, `parallelSafe: true`, and a strict no-argument schema when applicable.
86
+ - Avoid over-brittle trace markers. Prefer stable substrings like the tool name plus `"message_type":"tool_return_message"`.
87
+ - Keep requirements behavioral; put fragile implementation details in `modApiHints` only when needed.
88
+
89
+ ## Minimal scenario-suite example
90
+
91
+ ```json
92
+ {
93
+ "name": "Hello tool mod learner demo",
94
+ "slug": "hello-tool",
95
+ "objective": "Learn a trusted local mod that registers a read-only hello_mod_ping tool returning a fixed sentinel.",
96
+ "requirements": [
97
+ "Register a tool named hello_mod_ping.",
98
+ "The tool must accept no parameters, require no approval, be parallelSafe, and return the exact string HELLO-MOD-OK."
99
+ ],
100
+ "candidateDiversityHints": [
101
+ "Use the smallest possible tool-only implementation.",
102
+ "Add explicit defensive checks around the tool schema."
103
+ ],
104
+ "modApiHints": [
105
+ "Use export function activate(letta) or a default export.",
106
+ "Use letta.tools.register({ name, description, parameters, requiresApproval, parallelSafe, run }).",
107
+ "A no-argument tool schema is { \"type\": \"object\", \"properties\": {}, \"additionalProperties\": false }."
108
+ ],
109
+ "evaluation": {
110
+ "outputFormat": "stream-json",
111
+ "timeoutMs": 900000,
112
+ "maxTurns": 6,
113
+ "forbiddenTraceMarkers": ["[mods] failed to load", "loaded 0 mod(s)"],
114
+ "scenarios": [
115
+ {
116
+ "name": "happy-path",
117
+ "prompt": "Call the hello_mod_ping tool, then answer with the exact text HELLO-MOD-OK.",
118
+ "requiredResultMarkers": ["HELLO-MOD-OK"],
119
+ "requiredTraceMarkers": ["hello_mod_ping", "\"message_type\":\"tool_return_message\""]
120
+ },
121
+ {
122
+ "name": "negative-control",
123
+ "prompt": "Answer without calling tools: what is 2 + 2?",
124
+ "requiredResultMarkers": ["4"],
125
+ "forbiddenTraceMarkers": ["hello_mod_ping"]
126
+ }
127
+ ]
128
+ }
129
+ }
130
+ ```
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "<Display name> mod learner demo",
3
+ "slug": "example-mod-env",
4
+ "targetModName": "example-mod-env.ts",
5
+ "objective": "Learn a trusted local mod that <specific behavior>.",
6
+ "requirements": [
7
+ "<Concrete behavior requirement>",
8
+ "<Concrete eval-facing requirement>"
9
+ ],
10
+ "candidateDiversityHints": [
11
+ "<Alternative implementation strategy for candidate 1>",
12
+ "<Alternative implementation strategy for candidate 2>"
13
+ ],
14
+ "modApiHints": [
15
+ "Use export function activate(letta) or a default export.",
16
+ "Use only the trusted local mod API exposed through the letta argument; do not import from repo source files."
17
+ ],
18
+ "examples": [
19
+ {
20
+ "input": "<Short demo input>",
21
+ "expected": "<Expected behavior>"
22
+ }
23
+ ],
24
+ "evaluation": {
25
+ "outputFormat": "stream-json",
26
+ "timeoutMs": 900000,
27
+ "maxTurns": 8,
28
+ "forbiddenTraceMarkers": [
29
+ "[mods] failed to load",
30
+ "[extensions] failed to load",
31
+ "loaded 0 mod(s)",
32
+ "loaded 0 extension(s)"
33
+ ],
34
+ "scenarios": [
35
+ {
36
+ "name": "happy-path",
37
+ "memoryFiles": {
38
+ "reference/<fixture>.md": "# Fixture\n\nThe sentinel is <SENTINEL-OK>.\n"
39
+ },
40
+ "prompt": "<Prompt that triggers the mod and requires the sentinel in the final answer>. The memory directory is available in MEMORY_DIR.",
41
+ "requiredResultMarkers": ["<SENTINEL-OK>"],
42
+ "requiredTraceMarkers": ["<stable trace marker>"],
43
+ "forbiddenResultMarkers": ["<known bad final answer marker>"]
44
+ },
45
+ {
46
+ "name": "negative-control",
47
+ "prompt": "<Prompt where the mod behavior should not trigger>.",
48
+ "requiredResultMarkers": ["<ordinary answer marker>"],
49
+ "forbiddenResultMarkers": ["<mod-only marker>"],
50
+ "forbiddenTraceMarkers": [
51
+ "<tool or event marker that should not appear>"
52
+ ]
53
+ }
54
+ ],
55
+ "prompt": "Run all configured evaluation.scenarios."
56
+ }
57
+ }