@junejs/server 1.0.0-dev.14 → 1.0.0-dev.19

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.
@@ -0,0 +1,271 @@
1
+ // agent-compile.ts — compile an agent/ directory into a static module
2
+ // (_agent.gen.ts) so the SAME directory convention mounts where filesystem
3
+ // discovery cannot run: workerd has no fs, so discoverAgent (dynamic import +
4
+ // readFile) is native-only. The generated module is plain erasable TypeScript —
5
+ // static imports of tools/channels/connections, markdown inlined as string
6
+ // literals — bundleable by wrangler or Rolldown, importable under Node type
7
+ // stripping and bun test alike. No [[rules]] Text hack, no md loader plugins,
8
+ // no hand-maintained tool registry.
9
+ //
10
+ // Mirrors the content freeze (content.ts): a pure emitter (emitAgentModule)
11
+ // over a plain scan (scanAgentDir), with a thin fs wrapper
12
+ // (generateAgentModule) — the emitter is unit-testable without touching disk.
13
+ // Assembly logic is NOT duplicated here: the generated module exports the raw
14
+ // AgentModule shape, and both runtimes assemble it through
15
+ // @junejs/core/agent-config (assembleAgent natively, assembleDurable in a DO
16
+ // shell). Skill files are inlined raw and parsed at module init via core's
17
+ // parseSkill, so a parser improvement never requires regeneration.
18
+
19
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
20
+ import { basename, dirname, join, resolve } from "node:path";
21
+
22
+ import { stripJsonc } from "./tsconfig-jsx";
23
+
24
+ export const AGENT_MODULE_FILE = "_agent.gen.ts";
25
+
26
+ // The raw scan of an agent directory: file paths for code (imported statically
27
+ // by the emitted module), file contents for prose (inlined as literals).
28
+ export type AgentDirScan = {
29
+ dir: string;
30
+ // basename fallback when agent.ts is absent (mirrors discoverAgent).
31
+ name: string;
32
+ hasConfig: boolean;
33
+ instructions: string | null;
34
+ // instructions.<source>.md at the agent root — per-surface variants (#149).
35
+ surfaceVariants: { source: string; raw: string }[];
36
+ tools: string[]; // relative specifiers, e.g. "./tools/create_order.ts"
37
+ skills: { name: string; raw: string }[];
38
+ channels: string[]; // "./channels/slack.ts"
39
+ channelOverlays: { source: string; raw: string }[]; // channels/<source>.md
40
+ connections: string[]; // "./connections/teachify.ts"
41
+ };
42
+
43
+ // `_`-prefixed files are private by convention — most importantly the generated
44
+ // module itself, which must never be scanned back in. Sorted for determinism.
45
+ function list(dir: string, ext: string): string[] {
46
+ if (!existsSync(dir)) return [];
47
+ return readdirSync(dir, { withFileTypes: true })
48
+ .filter((e) => e.isFile() && e.name.endsWith(ext) && !e.name.startsWith("_"))
49
+ .map((e) => e.name)
50
+ .sort();
51
+ }
52
+
53
+ // Scan an agent directory (fs only — no dynamic imports; the point is to defer
54
+ // module loading to the bundler). Returns null when the directory holds nothing
55
+ // agent-shaped, so callers can probe candidate paths cheaply.
56
+ export function scanAgentDir(dir: string): AgentDirScan | null {
57
+ if (!existsSync(dir)) return null;
58
+ const instructionsFile = join(dir, "instructions.md");
59
+ const scan: AgentDirScan = {
60
+ dir,
61
+ name: basename(dir),
62
+ hasConfig: existsSync(join(dir, "agent.ts")),
63
+ instructions: existsSync(instructionsFile) ? readFileSync(instructionsFile, "utf8") : null,
64
+ surfaceVariants: readdirSync(dir, { withFileTypes: true })
65
+ .filter((e) => e.isFile())
66
+ .map((e) => e.name.match(/^instructions\.([a-z0-9_-]+)\.md$/))
67
+ .filter((m): m is RegExpMatchArray => m !== null)
68
+ .sort((a, b) => a[1]!.localeCompare(b[1]!))
69
+ .map((m) => ({ source: m[1]!, raw: readFileSync(join(dir, m[0]!), "utf8") })),
70
+ tools: list(join(dir, "tools"), ".ts").map((f) => `./tools/${f}`),
71
+ skills: list(join(dir, "skills"), ".md").map((f) => ({
72
+ name: basename(f, ".md"),
73
+ raw: readFileSync(join(dir, "skills", f), "utf8"),
74
+ })),
75
+ channels: list(join(dir, "channels"), ".ts").map((f) => `./channels/${f}`),
76
+ channelOverlays: list(join(dir, "channels"), ".md").map((f) => ({
77
+ source: basename(f, ".md"),
78
+ raw: readFileSync(join(dir, "channels", f), "utf8"),
79
+ })),
80
+ connections: list(join(dir, "connections"), ".ts").map((f) => `./connections/${f}`),
81
+ };
82
+ const empty =
83
+ !scan.hasConfig &&
84
+ scan.instructions === null &&
85
+ !scan.tools.length &&
86
+ !scan.skills.length &&
87
+ !scan.channels.length &&
88
+ !scan.surfaceVariants.length &&
89
+ !scan.channelOverlays.length &&
90
+ !scan.connections.length;
91
+ return empty ? null : scan;
92
+ }
93
+
94
+ // A stable, readable import identifier from a file specifier: "./tools/create_order.ts"
95
+ // → "tool_create_order". Collisions (foo-bar vs foo_bar) get a numeric suffix.
96
+ function identFor(prefix: string, spec: string, taken: Set<string>): string {
97
+ const stem = basename(spec).replace(/\.ts$/, "").replace(/[^A-Za-z0-9]/g, "_");
98
+ let id = `${prefix}_${stem}`;
99
+ for (let i = 2; taken.has(id); i++) id = `${prefix}_${stem}_${i}`;
100
+ taken.add(id);
101
+ return id;
102
+ }
103
+
104
+ export type EmitOptions = {
105
+ // Keep ".ts" on relative import specifiers. Required by consumers that run
106
+ // the module under Node's native type stripping (Node never resolves
107
+ // extensionless imports); rejected (TS5097) by tsconfigs without
108
+ // allowImportingTsExtensions. generateAgentModule sniffs the consumer's
109
+ // tsconfig; pass explicitly to override.
110
+ tsExtensions?: boolean;
111
+ };
112
+
113
+ // Emit the module source for a scan. Pure — the golden test pins this output.
114
+ export function emitAgentModule(scan: AgentDirScan, opts?: EmitOptions): string {
115
+ const spec = (s: string) => JSON.stringify(opts?.tsExtensions ? s : s.replace(/\.ts$/, ""));
116
+ const taken = new Set<string>();
117
+ const imports: string[] = [];
118
+ const toolIds = scan.tools.map((s) => {
119
+ const id = identFor("tool", s, taken);
120
+ imports.push(`import ${id} from ${spec(s)};`);
121
+ return id;
122
+ });
123
+ const channelIds = scan.channels.map((s) => {
124
+ const id = identFor("channel", s, taken);
125
+ imports.push(`import ${id} from ${spec(s)};`);
126
+ return { key: basename(s, ".ts"), id };
127
+ });
128
+ const connectionIds = scan.connections.map((s) => {
129
+ const id = identFor("connection", s, taken);
130
+ imports.push(`import ${id} from ${spec(s)};`);
131
+ return id;
132
+ });
133
+ if (scan.hasConfig) imports.push(`import config from ${spec("./agent.ts")};`);
134
+
135
+ const lines: string[] = [
136
+ "// AUTO-GENERATED by June — do not edit. Compiled from this agent/ directory so",
137
+ "// the definition mounts without filesystem discovery (workerd has no fs).",
138
+ "// Regenerate with `june gen` after adding, removing, or editing agent files.",
139
+ ...imports,
140
+ scan.skills.length
141
+ ? `import { parseSkill, type AgentModule } from "@junejs/core/agent-config";`
142
+ : `import type { AgentModule } from "@junejs/core/agent-config";`,
143
+ "",
144
+ ];
145
+ if (!scan.hasConfig) lines.push(`const config = { name: ${JSON.stringify(scan.name)} };`, "");
146
+
147
+ lines.push(
148
+ "export const agentModule: AgentModule = {",
149
+ " config,",
150
+ ` instructions: ${JSON.stringify(scan.instructions ?? "")},`,
151
+ scan.surfaceVariants.length
152
+ ? ` surfaceInstructions: {\n${scan.surfaceVariants.map((v) => ` ${JSON.stringify(v.source)}: ${JSON.stringify(v.raw)},`).join("\n")}\n },`
153
+ : " surfaceInstructions: {},",
154
+ ` tools: [${toolIds.join(", ")}],`,
155
+ scan.skills.length
156
+ ? ` skills: [\n${scan.skills.map((s) => ` parseSkill(${JSON.stringify(s.name)}, ${JSON.stringify(s.raw)}),`).join("\n")}\n ],`
157
+ : " skills: [],",
158
+ channelIds.length
159
+ ? ` channels: { ${channelIds.map((c) => `${JSON.stringify(c.key)}: ${c.id}`).join(", ")} },`
160
+ : " channels: {},",
161
+ scan.channelOverlays.length
162
+ ? ` channelInstructions: {\n${scan.channelOverlays.map((o) => ` ${JSON.stringify(o.source)}: ${JSON.stringify(o.raw)},`).join("\n")}\n },`
163
+ : " channelInstructions: {},",
164
+ ` connections: [${connectionIds.join(", ")}],`,
165
+ "};",
166
+ "export default agentModule;",
167
+ "",
168
+ );
169
+ return lines.join("\n");
170
+ }
171
+
172
+ // Locate the agent directory for a project root: a June app keeps it at
173
+ // app/<dir> (the app.ts convention); a wrangler-first worker keeps it at
174
+ // <root>/<dir>. `dirName` follows june.config's agent.runtime.dir (default
175
+ // "agent").
176
+ export function findAgentDir(root: string, dirName = "agent"): string | null {
177
+ for (const candidate of [join(root, "app", dirName), join(root, dirName)]) {
178
+ if (existsSync(candidate)) return candidate;
179
+ }
180
+ return null;
181
+ }
182
+
183
+ // Whether the consumer's tsconfig keeps ".ts" on import specifiers
184
+ // (allowImportingTsExtensions): the nearest tsconfig.json walking up from the
185
+ // agent dir, parsed as JSONC (same stripJsonc as tsconfig-jsx.ts — a commented-
186
+ // out option must not count) and resolved through `extends` (relative, bare
187
+ // package specifiers, and the TS 5 array form; own options win, later array
188
+ // entries beat earlier ones). Explicit opts.tsExtensions overrides the sniff.
189
+ type TsconfigLite = { extends?: string | string[]; compilerOptions?: { allowImportingTsExtensions?: boolean } };
190
+
191
+ function readTsconfig(path: string): TsconfigLite | undefined {
192
+ try {
193
+ return JSON.parse(stripJsonc(readFileSync(path, "utf8"))) as TsconfigLite;
194
+ } catch {
195
+ return undefined; // absent or unparsable — treat as "not declared"
196
+ }
197
+ }
198
+
199
+ // Resolve an `extends` specifier from the extending config's directory: relative
200
+ // paths directly, bare specifiers through the nearest node_modules walking up.
201
+ // tsc appends ".json" when the target has no extension — mirror that.
202
+ function resolveExtends(fromDir: string, spec: string): string | undefined {
203
+ const withJson = (p: string) => (existsSync(p) ? p : !p.endsWith(".json") && existsSync(`${p}.json`) ? `${p}.json` : undefined);
204
+ if (spec.startsWith(".")) return withJson(resolve(fromDir, spec));
205
+ for (let d = fromDir; ; ) {
206
+ const candidate = withJson(join(d, "node_modules", spec));
207
+ if (candidate) return candidate;
208
+ const parent = dirname(d);
209
+ if (parent === d) return undefined;
210
+ d = parent;
211
+ }
212
+ }
213
+
214
+ function resolveAllowTsExtensions(path: string, depth: number): boolean | undefined {
215
+ if (depth > 8) return undefined; // extends cycles/degenerate chains — give up
216
+ const tc = readTsconfig(path);
217
+ if (!tc) return undefined;
218
+ const own = tc.compilerOptions?.allowImportingTsExtensions;
219
+ if (own !== undefined) return own;
220
+ // TS extends-array semantics: later entries override earlier ones.
221
+ const bases = typeof tc.extends === "string" ? [tc.extends] : [...(tc.extends ?? [])].reverse();
222
+ for (const spec of bases) {
223
+ const base = resolveExtends(dirname(path), spec);
224
+ const inherited = base ? resolveAllowTsExtensions(base, depth + 1) : undefined;
225
+ if (inherited !== undefined) return inherited;
226
+ }
227
+ return undefined;
228
+ }
229
+
230
+ function sniffTsExtensions(dir: string): boolean {
231
+ for (let d = dir; ; ) {
232
+ const candidate = join(d, "tsconfig.json");
233
+ if (existsSync(candidate)) return resolveAllowTsExtensions(candidate, 0) ?? false;
234
+ const parent = dirname(d);
235
+ if (parent === d) return false;
236
+ d = parent;
237
+ }
238
+ }
239
+
240
+ // Generate <dir>/_agent.gen.ts. With `check: true` nothing is written — `stale`
241
+ // reports whether the file on disk differs from what would be generated (the CI
242
+ // gate; `june gen && git diff --exit-code` works too). Returns null when the
243
+ // directory holds nothing agent-shaped.
244
+ export function generateAgentModule(
245
+ dir: string,
246
+ opts?: { check?: boolean } & EmitOptions,
247
+ ): { file: string; code: string; stale: boolean; written: boolean } | null {
248
+ const scan = scanAgentDir(dir);
249
+ if (!scan) return null;
250
+ for (const o of scan.channelOverlays) {
251
+ console.warn(
252
+ `[june] deprecated: channels/${o.source}.md — move it to instructions.${o.source}.md at the agent root (per-surface behavior is agent-level; see junebuild/june#149).`,
253
+ );
254
+ }
255
+ // A variant-shaped file the grammar doesn't parse yet must never be silently
256
+ // inert (the same failure class this whole convention exists to kill).
257
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
258
+ if (e.isFile() && /^instructions\..+\.md$/.test(e.name) && !/^instructions\.[a-z0-9_-]+\.md$/.test(e.name)) {
259
+ console.warn(
260
+ `[june] ${e.name}: unrecognized instructions variant — only single-segment sources (instructions.<source>.md) are parsed today; locale variants are a planned follow-up (junebuild/june#149). This file is ignored.`,
261
+ );
262
+ }
263
+ }
264
+ const code = emitAgentModule(scan, { tsExtensions: opts?.tsExtensions ?? sniffTsExtensions(dir) });
265
+ const file = join(dir, AGENT_MODULE_FILE);
266
+ const existing = existsSync(file) ? readFileSync(file, "utf8") : null;
267
+ const stale = existing !== code;
268
+ if (opts?.check) return { file, code, stale, written: false };
269
+ if (stale) writeFileSync(file, code);
270
+ return { file, code, stale, written: stale };
271
+ }
@@ -3,17 +3,21 @@
3
3
  // An agent is a DIRECTORY, not a config object. Location determines function:
4
4
  //
5
5
  // agent/
6
- // agent.ts → default-exports a plain config { name, model?, description? }
7
- // instructions.md → system prompt
8
- // tools/*.ts → each default-exports a defineAction (a tool)
9
- // skills/*.md → each a procedure, loaded on demand (progressive disclosure)
10
- // channels/*.ts → each default-exports a Channel (an inbound edge)
11
- // connections/*.ts → each default-exports a Connection (an outbound tool source)
6
+ // agent.ts → default-exports a plain config { name, model?, surfaces? }
7
+ // instructions.md → base system prompt
8
+ // instructions.<source>.md → per-surface instruction variant (#149)
9
+ // tools/*.ts → each default-exports a defineAction (a tool)
10
+ // skills/*.md → each a procedure, loaded on demand (progressive disclosure)
11
+ // channels/*.ts → each default-exports a Channel (an inbound edge — pure transport)
12
+ // channels/*.md → DEPRECATED overlay location (warned; use instructions.<source>.md)
13
+ // connections/*.ts → each default-exports a Connection (an outbound tool source)
12
14
  //
13
15
  // There is no central registry to keep in sync — the directory IS the manifest.
14
16
  // Because tools are `defineAction`s, the SAME directory is also an MCP server and
15
17
  // a `.agent` manifest (surfaces land in a later step). fs lives here in the host
16
- // package; the pure assembly is @junejs/core/agent-config's defineAgent().
18
+ // package; the pure assembly is @junejs/core/agent-config's assembleAgent() — the
19
+ // SAME entry point a compiled agent module (agent-compile.ts, the edge path) goes
20
+ // through, so native discovery and the generated module cannot drift.
17
21
 
18
22
  import { existsSync } from "node:fs";
19
23
  import { readdir, readFile } from "node:fs/promises";
@@ -21,52 +25,52 @@ import { basename, extname, join } from "node:path";
21
25
  import { pathToFileURL } from "node:url";
22
26
 
23
27
  import type { AnyAction } from "@junejs/core/agent";
24
- import { defineAgent, resolveChannel, type AgentConfigFile, type AgentDefinition, type Channel, type ChannelFactory, type Skill } from "@junejs/core/agent-config";
25
- import { connectAll, type Connection } from "@junejs/core/connections";
28
+ import { assembleAgent, parseSkill, type AgentConfigFile, type AgentDefinition, type AgentModule, type Channel, type ChannelFactory, type Skill } from "@junejs/core/agent-config";
29
+ import type { Connection } from "@junejs/core/connections";
26
30
 
31
+ // `_`-prefixed files are private by convention (mirrors the app/ router) — most
32
+ // importantly the generated `_agent.gen.ts` itself, which must never be scanned
33
+ // back in as a tool/channel.
27
34
  async function scan(dir: string, ext: string): Promise<string[]> {
28
35
  if (!existsSync(dir)) return [];
29
36
  const entries = await readdir(dir, { withFileTypes: true });
30
37
  return entries
31
- .filter((e) => e.isFile() && extname(e.name) === ext)
38
+ .filter((e) => e.isFile() && extname(e.name) === ext && !e.name.startsWith("_"))
32
39
  .map((e) => join(dir, e.name))
33
40
  .sort();
34
41
  }
35
42
 
36
- // Optional YAML-ish frontmatter: --- \n key: value \n --- \n body. Falls back to
37
- // the first non-empty line (minus a leading "# ") as the description.
38
- function parseSkill(name: string, text: string): Skill {
39
- if (text.startsWith("---")) {
40
- const end = text.indexOf("\n---", 3);
41
- if (end !== -1) {
42
- const front = text.slice(3, end);
43
- const body = text.slice(end + 4).replace(/^\s*\n/, "");
44
- const meta: Record<string, string> = {};
45
- for (const line of front.split("\n")) {
46
- const m = line.match(/^\s*(\w+)\s*:\s*(.+?)\s*$/);
47
- if (m) meta[m[1]!] = m[2]!;
48
- }
49
- return { name: meta.name ?? name, description: meta.description ?? "", body };
50
- }
51
- }
52
- const firstLine = text.split("\n").find((l) => l.trim()) ?? "";
53
- return { name, description: firstLine.replace(/^#\s*/, ""), body: text };
54
- }
55
-
56
- // Discover an agent from its directory, returning the assembled AgentDefinition
57
- // (tools already adapted from defineActions; read_skill added if any skills
58
- // exist). Mount it on a runtime with createNativeRuntime({ [agent.name]: { model,
59
- // tools: agent.tools } }).
60
- export async function discoverAgent(dir: string): Promise<AgentDefinition> {
43
+ // Read an agent directory into its raw, unassembled shape (fs + dynamic import —
44
+ // native only). The pure sibling for the edge is agent-compile.ts, which emits
45
+ // the same AgentModule as static code.
46
+ export async function discoverAgentModule(dir: string): Promise<AgentModule> {
61
47
  const configFile = join(dir, "agent.ts");
62
48
  const config: AgentConfigFile = existsSync(configFile)
63
49
  ? ((await import(pathToFileURL(configFile).href)).default as AgentConfigFile)
64
50
  : { name: basename(dir) };
65
51
 
66
52
  const instructionsFile = join(dir, "instructions.md");
67
- const instructions = existsSync(instructionsFile)
68
- ? await readFile(instructionsFile, "utf8")
69
- : (config.instructions ?? "");
53
+ const instructions = existsSync(instructionsFile) ? await readFile(instructionsFile, "utf8") : "";
54
+
55
+ // instructions.<source>.md — the agent's per-surface instruction variants
56
+ // (#149): applied to turns arriving through that source, composed per
57
+ // config.surfaces (append by default, "replace" to stand alone).
58
+ const surfaceInstructions: Record<string, string> = {};
59
+ if (existsSync(dir)) {
60
+ for (const e of await readdir(dir, { withFileTypes: true })) {
61
+ if (!e.isFile() || !/^instructions\..+\.md$/.test(e.name)) continue;
62
+ const m = e.name.match(/^instructions\.([a-z0-9_-]+)\.md$/);
63
+ if (m) {
64
+ surfaceInstructions[m[1]!] = await readFile(join(dir, e.name), "utf8");
65
+ } else {
66
+ // A variant-shaped file the grammar doesn't parse yet (e.g. a locale
67
+ // suffix, instructions.slack.zh-TW.md) must never be SILENTLY inert.
68
+ console.warn(
69
+ `[june] ${e.name}: unrecognized instructions variant — only single-segment sources (instructions.<source>.md) are parsed today; locale variants are a planned follow-up (junebuild/june#149). This file is ignored.`,
70
+ );
71
+ }
72
+ }
73
+ }
70
74
 
71
75
  const tools: AnyAction[] = [];
72
76
  for (const f of await scan(join(dir, "tools"), ".ts")) {
@@ -80,34 +84,41 @@ export async function discoverAgent(dir: string): Promise<AgentDefinition> {
80
84
  }
81
85
 
82
86
  // A channel module default-exports a Channel OR a `(env) => Channel` factory (the
83
- // form workerd needs — secrets live in env, not at module scope). On native, env is
84
- // process.env (present at load), so resolve here; the plain form stays supported.
85
- const channels: Channel[] = [];
87
+ // form workerd needs — secrets live in env, not at module scope). Resolution is
88
+ // assembleAgent's job (native: process.env; a DO: its own env) — keep raw here.
89
+ const channels: Record<string, Channel | ChannelFactory> = {};
86
90
  for (const f of await scan(join(dir, "channels"), ".ts")) {
87
91
  const mod = await import(pathToFileURL(f).href);
88
- if (mod.default) channels.push(resolveChannel(mod.default as Channel | ChannelFactory, process.env));
92
+ if (mod.default) channels[basename(f, ".ts")] = mod.default as Channel | ChannelFactory;
93
+ }
94
+
95
+ // channels/<source>.md — the DEPRECATED overlay convention (#149): behavior
96
+ // prose belongs at the agent level (instructions.<source>.md), not beside
97
+ // transport code. Still honored for one dev-series, loudly.
98
+ const channelInstructions: Record<string, string> = {};
99
+ for (const f of await scan(join(dir, "channels"), ".md")) {
100
+ const source = basename(f, ".md");
101
+ console.warn(
102
+ `[june] deprecated: channels/${source}.md — move it to instructions.${source}.md at the agent root (per-surface behavior is agent-level; see junebuild/june#149).`,
103
+ );
104
+ channelInstructions[source] = await readFile(f, "utf8");
89
105
  }
90
106
 
91
- // connections/*.ts — outbound: wire external MCP/OpenAPI servers, turning their
92
- // operations into `<connection>__<tool>` defineActions that join `tools`.
93
- const connectionDefs: Connection[] = [];
107
+ // connections/*.ts — outbound: definitions of external MCP/OpenAPI servers;
108
+ // assembleAgent wires them (connectAll) where the agent actually runs.
109
+ const connections: Connection[] = [];
94
110
  for (const f of await scan(join(dir, "connections"), ".ts")) {
95
111
  const mod = await import(pathToFileURL(f).href);
96
- if (mod.default) connectionDefs.push(mod.default as Connection);
112
+ if (mod.default) connections.push(mod.default as Connection);
97
113
  }
98
- const { actions: connectionActions, report: connections } = connectionDefs.length
99
- ? await connectAll(connectionDefs)
100
- : { actions: [], report: [] };
101
- tools.push(...connectionActions);
102
114
 
103
- return defineAgent({
104
- name: config.name,
105
- model: config.model,
106
- description: config.description,
107
- instructions,
108
- tools,
109
- skills,
110
- channels,
111
- connections,
112
- });
115
+ return { config, instructions, surfaceInstructions, tools, skills, channels, channelInstructions, connections };
116
+ }
117
+
118
+ // Discover an agent from its directory, returning the assembled AgentDefinition
119
+ // (tools already adapted from defineActions; read_skill added if any skills
120
+ // exist). Mount it on a runtime with createNativeRuntime({ [agent.name]: { model,
121
+ // tools: agent.tools } }).
122
+ export async function discoverAgent(dir: string): Promise<AgentDefinition> {
123
+ return assembleAgent(await discoverAgentModule(dir), process.env);
113
124
  }
@@ -19,6 +19,7 @@ import {
19
19
  AgentSession,
20
20
  ResumeAuthorizationError,
21
21
  withSystem,
22
+ type ChannelPolicy,
22
23
  type EventSink,
23
24
  type TurnEvent,
24
25
  type Model,
@@ -35,6 +36,7 @@ import type { Resources } from "@junejs/core/resources";
35
36
  import {
36
37
  channelDispatch,
37
38
  DeliverUnsupportedError,
39
+ actionToTool,
38
40
  resolveChannel,
39
41
  type AgentDefinition,
40
42
  type Channel,
@@ -42,6 +44,7 @@ import {
42
44
  type ChannelFactory,
43
45
  type ResumeDeliveryTarget,
44
46
  } from "@junejs/core/agent-config";
47
+ import { connectAll, type Connection } from "@junejs/core/connections";
45
48
  import { ensureScope, runInScope } from "@junejs/db";
46
49
  import { isolateLocal } from "./isolate-local";
47
50
  import { assertCoreRuntimeVersion } from "./core-version";
@@ -198,9 +201,12 @@ export type DoAgentDef = {
198
201
  model: Model;
199
202
  tools: Tool[];
200
203
  instructions?: string;
201
- // Per-channel-source system overlays (see AgentDefinition.channelInstructions) — the
202
- // shared agent branches by real inbound source, no userText marker.
203
- channelInstructions?: Record<string, string>;
204
+ // Per-channel-source turn policies (see AgentDefinition.channelInstructions):
205
+ // an overlay string, or a ChannelPolicy carrying overlay/overlayMode/denyTools
206
+ // (#149 — derived from agent-level instructions.<source>.md + surfaces config
207
+ // by assembleDurable). The shared agent branches by real inbound source, no
208
+ // userText marker.
209
+ channelInstructions?: Record<string, string | ChannelPolicy>;
204
210
  // Channels whose CAPABILITY tools should be available to this session's turns. Their
205
211
  // tools are built HERE, in the DO isolate, from `env` — because a channel's tool `run`
206
212
  // is a closure (over the bot token, etc.) that can't cross the worker→DO RPC. This is
@@ -208,7 +214,17 @@ export type DoAgentDef = {
208
214
  // channel factories you mount on the worker, plus this DO's env.
209
215
  channels?: (Channel | ChannelFactory)[];
210
216
  env?: unknown;
211
- resources?: Resources;
217
+ // A resolved bag, OR an env-driven — possibly async — provider
218
+ // (bindWorkerResources' shape): opening a D1 handle is async and a DO
219
+ // constructor cannot await, so a provider is resolved lazily at the first
220
+ // turn (once per DO instance) with this def's `env`.
221
+ resources?: Resources | ((env: unknown) => Promise<Resources> | Resources);
222
+ // Outbound connection DEFINITIONS (external MCP/OpenAPI servers). Wiring them
223
+ // is network I/O (connectAll), which a DO constructor cannot await — resolved
224
+ // lazily at the first turn, their tools merged into the agent's tool set
225
+ // before any session is constructed. The native equivalent happens at
226
+ // assembly (assembleAgent → connectAll).
227
+ connections?: Connection[];
212
228
  services?: unknown;
213
229
  // Called on every turn.failed for this session — the app's seam for routing turn
214
230
  // failures to its own telemetry (Sentry, a ledger, …). Providing it REPLACES the
@@ -252,16 +268,57 @@ export class AgentDurableObject {
252
268
  private readonly model: Model;
253
269
  private readonly tools: Tool[];
254
270
  private readonly channels: Channel[];
255
- private readonly channelInstructions?: Record<string, string>;
256
- // Built from the DO's env in the constructor (this isolate), shared across turns —
257
- // env is stable per isolate, like bindWorkerResources memoizes per worker isolate.
258
- private readonly resources: Resources;
271
+ private readonly channelInstructions?: Record<string, string | ChannelPolicy>;
272
+ // A bag is used as-is; a provider (bindWorkerResources' shape) is resolved
273
+ // lazily at the first turn with the def's env — opening a D1 handle is async
274
+ // and this constructor cannot await. Resolved once, shared across turns (env
275
+ // is stable per isolate, like bindWorkerResources memoizes per worker isolate).
276
+ private readonly resourcesInput: DoAgentDef["resources"];
277
+ private resourcesOpened: Promise<Resources> | undefined;
278
+ private readonly connectionsInput: Connection[] | undefined;
279
+ private connectionsOpened: Promise<void> | undefined;
280
+ private readonly doEnv: unknown;
259
281
  private readonly services: unknown;
282
+ private resolveResources(): Promise<Resources> {
283
+ return (this.resourcesOpened ??= Promise.resolve(
284
+ typeof this.resourcesInput === "function" ? this.resourcesInput(this.doEnv) : (this.resourcesInput ?? {}),
285
+ ));
286
+ }
287
+ // Wire outbound connections once (network I/O the constructor can't await):
288
+ // their tools merge into this.tools BEFORE any session is constructed — the
289
+ // ready() callers hoist this ahead of resolveSession. A failed connection is
290
+ // reported and skipped (connectAll never throws), matching native assembly.
291
+ private resolveConnections(): Promise<void> {
292
+ return (this.connectionsOpened ??= (async () => {
293
+ if (!this.connectionsInput?.length) return;
294
+ const { actions, report } = await connectAll(this.connectionsInput);
295
+ for (const r of report) {
296
+ if (r.error) console.error(`[june] agent "${this.name}": connection "${r.name}" (${r.url}) failed: ${r.error}`);
297
+ }
298
+ const seen = new Set(this.tools.map((t) => t.spec.name));
299
+ for (const a of actions) {
300
+ const tool = actionToTool(a);
301
+ if (seen.has(tool.spec.name)) {
302
+ throw new Error(`AgentDurableObject(${this.name}): duplicate tool name "${tool.spec.name}" from a connection — rename so dispatch is unambiguous.`);
303
+ }
304
+ seen.add(tool.spec.name);
305
+ this.tools.push(tool);
306
+ }
307
+ })());
308
+ }
309
+ // Everything a turn needs opened BEFORE its synchronous scope-and-subscribe
310
+ // section: resources (possibly an async provider) and outbound connections.
311
+ private async ready(): Promise<Resources> {
312
+ const [resources] = await Promise.all([this.resolveResources(), this.resolveConnections()]);
313
+ return resources;
314
+ }
260
315
  constructor(state: DurableObjectState, def: DoAgentDef) {
261
316
  assertCoreRuntimeVersion(`AgentDurableObject(${def.name ?? "agent"})`); // #94: fail power-on, not mid-turn
262
317
  const store = new DoSessionStore(state.storage);
263
318
  const model = def.instructions ? withSystem(def.model, def.instructions) : def.model;
264
- this.resources = def.resources ?? {};
319
+ this.resourcesInput = def.resources;
320
+ this.connectionsInput = def.connections;
321
+ this.doEnv = def.env;
265
322
  this.services = def.services;
266
323
  const name = def.name ?? "agent";
267
324
  // Failure observability (#76): a turn that dies after the fast-ACK has no other
@@ -321,6 +378,18 @@ export class AgentDurableObject {
321
378
  this.tools = tools;
322
379
  this.channels = channels;
323
380
  this.channelInstructions = def.channelInstructions;
381
+ // Orphan-policy check (#149): assembly can't validate surface keys against
382
+ // factory channels (they resolve only here, with env) — so warn now, once,
383
+ // when a policy's source matches no resolved channel. A policy that never
384
+ // fires is the old silent failure; a warning is its floor.
385
+ if (def.channelInstructions) {
386
+ const names = new Set(channels.map((c) => c.name));
387
+ for (const source of Object.keys(def.channelInstructions)) {
388
+ if (!names.has(source)) {
389
+ console.warn(`[june] agent "${name}": surface policy "${source}" matches no channel wired into this DO (${[...names].join(", ") || "none"}) — it will never fire.`);
390
+ }
391
+ }
392
+ }
324
393
  }
325
394
  // Resolve THE session for this DO: explicit key (from a routed request) → persisted
326
395
  // key (a prior life learned it) → "self". A key that contradicts the persisted or
@@ -368,8 +437,9 @@ export class AgentDurableObject {
368
437
  // pipeline does; without it runInScope is a pass-through and ambient reads throw.
369
438
  async turn(input: { turnId?: string; userText: string; event?: InboundEvent; trigger?: ProactiveTrigger; session?: string }): Promise<string> {
370
439
  await ensureScope();
440
+ const resources = await this.ready();
371
441
  const session = this.resolveSession(input.session);
372
- return runInScope({ resources: this.resources, services: this.services }, () => session.turn(input));
442
+ return runInScope({ resources, services: this.services }, () => session.turn(input));
373
443
  }
374
444
  // Fire-and-forget for custom shells (#77): resolves once the turn is durably ACCEPTED;
375
445
  // it then runs under the DO's own lifetime (a DO stays alive while it has pending
@@ -377,8 +447,9 @@ export class AgentDurableObject {
377
447
  // the default turn-failure log / onTurnError (#76).
378
448
  async start(input: { turnId?: string; userText: string; event?: InboundEvent; trigger?: ProactiveTrigger; session?: string }): Promise<{ turnId: string }> {
379
449
  await ensureScope();
450
+ const resources = await this.ready();
380
451
  const session = this.resolveSession(input.session);
381
- return runInScope({ resources: this.resources, services: this.services }, () => session.start(input));
452
+ return runInScope({ resources, services: this.services }, () => session.start(input));
382
453
  }
383
454
  // Session reset (#129) for custom shells — the direct sibling of the /reset route. No
384
455
  // scope needed: reset touches only the store (no tools run).
@@ -432,12 +503,14 @@ export class AgentDurableObject {
432
503
  }
433
504
  // start() schedules the turn on the chain WITHIN the scope, so it runs with ambient
434
505
  // db/services (ALS propagates to the .then continuation registered here); subscribing
435
- // happens synchronously right after, before any event can emit.
506
+ // happens synchronously right after, before any event can emit. Resources resolve
507
+ // BEFORE this block — the section from start() to the subscription stays synchronous.
508
+ const resources = await this.ready();
436
509
  let session: AgentSession;
437
510
  let started: { turnId: string };
438
511
  try {
439
512
  session = this.resolveSession(key);
440
- started = runInScope({ resources: this.resources, services: this.services }, () => session.start({ userText, turnId, event, trigger, replace }));
513
+ started = runInScope({ resources, services: this.services }, () => session.start({ userText, turnId, event, trigger, replace }));
441
514
  } catch (err) {
442
515
  // e.g. the session is suspended awaiting input, or the key mis-matches this
443
516
  // object's identity — a client-resolvable conflict, not a crash
@@ -470,7 +543,7 @@ export class AgentDurableObject {
470
543
  const events: AsyncIterable<TurnEvent> = { [Symbol.asyncIterator]: () => it };
471
544
  const target = { channelId: event!.channelId, threadId: event!.threadId, recipientUserId: event!.user?.id, recipientTeamId: event!.teamId };
472
545
  Promise.resolve()
473
- .then(() => runInScope({ resources: this.resources, services: this.services }, () => deliverChannel!.deliver!(target, events, { session: this.sessionKey })))
546
+ .then(() => runInScope({ resources, services: this.services }, () => deliverChannel!.deliver!(target, events, { session: this.sessionKey })))
474
547
  .catch(logRenderFailure)
475
548
  .finally(() => { void it.return?.(); });
476
549
  } catch (err) {
@@ -515,10 +588,13 @@ export class AgentDurableObject {
515
588
  );
516
589
  }
517
590
  }
591
+ // Same discipline as /turn: resolve resources before the synchronous
592
+ // resume-then-subscribe section.
593
+ const resources = await this.ready();
518
594
  let session: AgentSession;
519
595
  try {
520
596
  session = this.resolveSession(key);
521
- runInScope({ resources: this.resources, services: this.services }, () => session.resume(turnId, inputId, input, { by }));
597
+ runInScope({ resources, services: this.services }, () => session.resume(turnId, inputId, input, { by }));
522
598
  } catch (err) {
523
599
  const message = err instanceof Error ? err.message : String(err);
524
600
  // 403: the resumer may not answer; 409: not suspended / wrong turn / wrong input id / key mismatch
@@ -538,7 +614,7 @@ export class AgentDurableObject {
538
614
  const it = observeTurnEvents(session, turnId)[Symbol.asyncIterator]();
539
615
  const events: AsyncIterable<TurnEvent> = { [Symbol.asyncIterator]: () => it };
540
616
  Promise.resolve()
541
- .then(() => runInScope({ resources: this.resources, services: this.services }, () => resumeChannel!.deliverResume!(target!, events, { session: this.sessionKey })))
617
+ .then(() => runInScope({ resources, services: this.services }, () => resumeChannel!.deliverResume!(target!, events, { session: this.sessionKey })))
542
618
  .catch(logRenderFailure)
543
619
  .finally(() => { void it.return?.(); });
544
620
  } catch (err) {