feat: agent-level surfaces — instructions.<source>.md + surfaces mechanics - #150
Conversation
…enyTools (#149) ChannelPolicy joins the runtime seam: AgentSession's per-source map now accepts { overlay, overlayMode, denyTools } beside the classic string. overlayMode "replace" makes the overlay the turn's ENTIRE system prompt (withSystem consumes systemMode; adapters only ever see a final system) — the mechanical form of prose that begged 'disregard the above'. denyTools filters at the same per-turn choke point as requiresPrincipal: unlisted to the model AND undispatchable (a hallucinated call fails as unknown tool — enforcement, not suggestion). Both persist through the suspend checkpoint, so a resumed turn keeps its policy. Engine tests pin append/replace/bare- string composition, per-source deny, and the failure contract.
…rivation (#149) Behavior prose is agent-level, not transport-level: channels stay pure transport (no overlay fields), and the per-source policies derive from instructions.<source>.md variants (AgentModule.surfaceInstructions) plus agent.ts surfaces mechanics ({ mode, denyTools }). Fail-loud edges: a mode without its variant file throws at assembly; a source authored in both the new pieces and the legacy channelInstructions map throws (one source of truth); an orphan policy key warns when channel names are known. defineAgent /assembleAgent/assembleDurable all derive through the one function, so native discovery and the compiled module cannot drift.
…channels/<name>.md (#149) Discovery and june gen pick up the agent-root surface variants (instructions.<source>.md → AgentModule.surfaceInstructions); the DO's policy map type widens to ChannelPolicy and the DO warns once when a policy's source matches no resolved channel (the assembly can't check factory names — the DO can). channels/<name>.md still works for one dev-series but warns loudly at discovery AND at gen, pointing to the new location. Fixture agent-ops moves to the new convention (slack surface variant + surfaces.slack denyTools) — the compiled≡discovered parity test covers the whole new path for free.
79cbca0 to
453996e
Compare
…-rebase The test landed on main from the #147 round with the pre-surfaces module literal; AgentModule.surfaceInstructions is required now.
453996e to
4501a71
Compare
There was a problem hiding this comment.
Pull request overview
Adds agent-level, source-specific instructions and enforceable surface policies across native and Durable Object runtimes.
Changes:
- Discovers and compiles
instructions.<source>.md. - Adds append/replace prompt modes and per-turn tool denial.
- Deprecates
channels/<source>.mdwith warnings and compatibility support.
Reviewed changes
Copilot reviewed 12 out of 13 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
.changeset/agent-surfaces.md |
Documents the new surface API. |
packages/core/src/agent-config.ts |
Defines and assembles surface policies. |
packages/core/src/agent-runtime.ts |
Enforces prompt modes and tool denial. |
packages/core/test/agent-config.test.ts |
Tests policy derivation and validation. |
packages/core/test/agent-runtime.test.ts |
Tests runtime policy enforcement. |
packages/june/src/agent-compile.ts |
Compiles surface instruction variants. |
packages/june/src/agent-discover.ts |
Discovers variants and warns on legacy overlays. |
packages/june/src/agent-durable.ts |
Passes policies into Durable Object sessions. |
packages/june/test/agent-compile.test.ts |
Tests surface variant scanning. |
packages/june/test/agent-discover.test.ts |
Tests discovered surface policies. |
packages/june/test/fixtures/agent-ops/_agent.gen.ts |
Updates generated fixture output. |
packages/june/test/fixtures/agent-ops/agent.ts |
Configures the Slack surface policy. |
packages/june/test/fixtures/agent-ops/instructions.slack.md |
Adds Slack-specific instructions. |
Suppressed comments (1)
packages/core/src/agent-runtime.ts:563
- An empty
instructions.<source>.mdis a valid discovered variant, but this truthiness check omits both the empty overlay and itsreplacemode. The turn therefore keeps the base prompt (or sends no per-turn system prompt) instead of using the variant as the entire prompt. Distinguishundefinedfrom""here.
for await (const d of model(msgs, specs, systemOverlay ? { system: systemOverlay, ...(systemMode ? { systemMode } : {}) } : undefined)) {
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| return (msgs, tools, opts) => { | ||
| const { systemMode, ...rest } = opts ?? {}; | ||
| const finalSystem = | ||
| systemMode === "replace" && opts?.system ? opts.system : [system, opts?.system].filter(Boolean).join("\n\n"); |
There was a problem hiding this comment.
Adopted in a041d5d. Both sites (withSystem's join and modelStep's opts gate) now key on !== undefined, so { system: "", systemMode: "replace" } yields an empty system prompt as documented. Pin test drives an empty replace overlay through a real turn — truthiness would have silently fallen back to the base.
| // Agent-level surface instruction variants (#149): instructions.<source>.md | ||
| // prose, keyed by source. Composition/deny mechanics live in | ||
| // config.surfaces; assembly derives the per-source policies from both. | ||
| surfaceInstructions: Record<string, string>; |
There was a problem hiding this comment.
Adopted in 7049443. surfaceInstructions is optional — assembly already treated it that way (opts.surfaceInstructions?.[source]), so pre-existing generated and hand-authored modules stay source-compatible; new generations still emit {}.
| // each channel's own overlay/overlayMode/denyTools declarations (#149); the map form | ||
| // remains as the programmatic escape hatch. Applied by the runtime (AgentSession). |
There was a problem hiding this comment.
Adopted in 7049443. The comment now describes the actual v2 derivation (agent-level instructions.
|
|
||
| How an agent behaves when reached over a given surface is agent behavior, not transport configuration — two agents sharing one channel carry different behavior; the transport carries none. The `channels/<source>.md` overlay convention conflated the two (and failed its blind-guess test 8/10: it reads as deletable docs). It is replaced by two agent-level pieces: | ||
|
|
||
| **`instructions.<source>.md`** at the agent root is the surface's instruction variant: discovered by convention (and inlined by `june gen` for the edge), applied to turns whose inbound event arrives through that source — the file alone activates it, composing after the base instructions by default. The name is its own documentation: in the measurement round the shape scored 10/10 on the blind-guess test the old convention failed, 80/80 across the full prediction quiz, and zero divergence on how the locale grammar generalizes (`instructions.slack.zh-TW.md`). |
There was a problem hiding this comment.
Adopted in 296f397, both halves. The changeset now labels the locale filename as the measured grammar-generalization result and states plainly that only single-segment sources parse today (locale variants = planned follow-up). And because a variant-shaped filename sitting silently inert is the exact failure class this convention exists to kill, discovery AND june gen now warn loudly on unparsed instructions.*.md names — covered by a test (file ignored, warning names it).
…not truthiness
{ system: "", systemMode: "replace" } fell back to appending the base at
both the withSystem join and the modelStep opts gate, contradicting the
documented 'the variant IS the system prompt' contract. Both sites now key
on !== undefined; a pin test drives an empty replace overlay through a turn.
Refs #149
…n comment
Required-ness broke source compatibility for every module generated before
the field existed (and hand-authored ones) — assembly already treats it as
optional, so the type now does too; new generations still emit {}. The
AgentDefinition.channelInstructions comment still described the withdrawn
v1 design (channel-declared overlay/overlayMode/denyTools); it now points
at the agent-level derivation.
Refs #149
…locale wording instructions.slack.zh-TW.md matched nothing and sat silently inert — the exact failure class this convention exists to kill. Discovery and june gen now warn loudly on variant-shaped filenames outside the single-segment grammar, pointing at the planned locale follow-up; the changeset stops presenting the locale filename as current behavior (it was the measured grammar-generalization result, now labeled as such). Refs #149
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 13 changed files in this pull request and generated no new comments.
Suppressed comments (3)
packages/june/src/agent-discover.ts:64
- This filename grammar is narrower than the source contract: both
Channel.nameandInboundEvent.sourceare unrestricted strings. A valid channel named, for example,teams.v2can be matched bysurfaces["teams.v2"], but its documentedinstructions.teams.v2.mdvariant is always classified as unrecognized and never applied. Either establish and enforce the same source-name restriction at the channel/event API, or define an unambiguous filename encoding that supports every valid source.
if (!e.isFile() || !/^instructions\..+\.md$/.test(e.name)) continue;
const m = e.name.match(/^instructions\.([a-z0-9_-]+)\.md$/);
if (m) {
surfaceInstructions[m[1]!] = await readFile(join(dir, e.name), "utf8");
packages/june/src/agent-compile.ts:263
- The warning is unreachable when an unrecognized variant is the directory's only agent-shaped file:
scanAgentDirexcludes it, returnsnull, and this function exits on line 249 before inspecting the directory. Thusjune gensilently ignores exactly the malformed-file case this block promises to report. Run this validation before the null return (and add a regression case withoutinstructions.md).
// A variant-shaped file the grammar doesn't parse yet must never be silently
// inert (the same failure class this whole convention exists to kill).
for (const e of readdirSync(dir, { withFileTypes: true })) {
packages/june/src/agent-compile.ts:69
- The generated-module scanner also cannot represent surface instructions for valid channel sources outside
[a-z0-9_-]+(for exampleteams.v2), even thoughChannel.nameandInboundEvent.sourceaccept arbitrary strings. This makes native and edge consistently unable to use the new prose convention for those channels; align the source API and filename grammar or introduce an unambiguous encoding.
surfaceVariants: readdirSync(dir, { withFileTypes: true })
.filter((e) => e.isFile())
.map((e) => e.name.match(/^instructions\.([a-z0-9_-]+)\.md$/))
.filter((m): m is RegExpMatchArray => m !== null)
.sort((a, b) => a[1]!.localeCompare(b[1]!))
.map((m) => ({ source: m[1]!, raw: readFileSync(join(dir, m[0]!), "utf8") })),
Closes #149. Stacked on #147 (shares the assembleDurable/DoAgentDef surfaces); after #147 merges this retargets to main automatically.
The shape (v2 of #149, after the design pivot recorded there)
Per-surface behavior is agent-level:
instructions.<source>.mdis discovered by convention (inlined byjune genfor the edge) and applies to turns arriving through that source — the file alone activates it (append default).agent.tssurfacesadds the mechanics the prose used to beg for:mode: "replace"— the variant IS the turn's system prompt;withSystemconsumes the mode and drops the base mechanically (no more bold "disregard the above").denyTools— removed at the same per-turn choke point asrequiresPrincipal: unlisted AND undispatchable; a hallucinated call fails with the sameunknown toolcontract. Enforcement, not suggestion. Persisted through the suspend checkpoint like the overlay, so resumed turns keep their policy.Fail-loud edges:
modewithout its variant file → assembly/build error; a source authored in both the new pieces and the legacychannelInstructionsmap → error (one source of truth); a policy matching no mounted channel → warning at assembly (native) or in the DO after factory resolution (edge) — the old silent-no-fire orphan, surfaced.channels/<name>.mdkeeps working for one dev-series with loud deprecation warnings at discovery and atjune gen. Channels carry no behavior prose — they are transport.Evidence (full trail on #149)
channels/slack.mdfailed 2/10, 20/20 agreement with the spec's two design-judgment calls (mode-without-file = build error; file-without-config = applies, append default), and 10/10 zero-divergence locale-grammar generalization.Implementation notes
One derivation function (
surfacePolicies) servesdefineAgent,assembleAgent, andassembleDurable— native discovery and the compiled module cannot drift (the existing compiled≡discovered parity test covers the new path with no new machinery). The engine seam isChannelPolicyonAgentSession's per-source map (bare strings still work — the classic overlay form).bun run cigreen: typecheck + full suite (engine tests pin append/replace/bare-string composition, per-source deny + failure contract, and the surfacePolicies edges).