From b83ba59d7bdd7afb8cbac5b8f79054b47fc93251 Mon Sep 17 00:00:00 2001 From: Noah Lindner Date: Wed, 12 Aug 2026 08:21:10 -0400 Subject: [PATCH] ref shape as schema: ^r\d+$ on every ref field, with self-correcting errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live observation (first night of the R4 gate): the ear twice passed timestamp- and channel-shaped strings as refs before self-correcting to the [rN] tag — one wasted call per pass. The ref fields on reply/react/ step_back/verdict now carry pattern ^r\d+$ in their schemas (models read schemas), descriptions lead with the anti-examples (never a timestamp, channel id, or thread id — labels, not addresses), and the rejection quotes the bad value back with the shape to copy. Co-Authored-By: Claude Fable 5 --- src/service.ts | 4 ++-- src/turn-runner/toolset.ts | 10 +++++----- test/resident.test.ts | 2 +- test/toolset.test.ts | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/service.ts b/src/service.ts index 9e942b5..e11f96c 100644 --- a/src/service.ts +++ b/src/service.ts @@ -427,7 +427,7 @@ export class Service { properties: { decision: { type: "string", enum: ["hold", "wake", "open_ask", "close_ask", "reopen_ask"] }, why: { type: "string" }, - ref: { type: "string" }, + ref: { type: "string", pattern: "^r\\d+$" }, itemId: { type: "string" }, }, }, @@ -436,7 +436,7 @@ export class Service { const a = args as { decision: string; why: string; ref?: string; itemId?: string }; const target = a.ref ? refs.get(a.ref) : undefined; if (a.ref && !target) { - return { success: false, output: "no such ref in this batch — use an [rN] tag from the lines you were shown" }; + return { success: false, output: `"${a.ref}" is not a ref — copy the [rN] tag (like r3) from the start of the line you are judging; timestamps and channel ids are labels, not addresses` }; } const venueId = target?.venueId; // hold/wake judge the conversation the message LIVES in (a top-level line is surface diff --git a/src/turn-runner/toolset.ts b/src/turn-runner/toolset.ts index d89e069..64dc01d 100644 --- a/src/turn-runner/toolset.ts +++ b/src/turn-runner/toolset.ts @@ -295,19 +295,19 @@ function replyTool(ctx: ToolsetContext): ToolFactory { spec: { name: "reply", description: - "Post a message into a conversation. ref is the [rN] tag from the line you're answering (a message ref replies in its thread; a conversation ref posts there). Refs come only from what you can see — there is no other way to address a room.", + "Post a message into a conversation. ref is the [rN] tag copied from the start of the line you're answering — always the r-number (like r3), never a timestamp, channel id, or thread id (those are labels, not addresses). A message ref replies in its thread; a conversation ref posts there. Refs come only from what you can see — there is no other way to address a room.", inputSchema: { type: "object", additionalProperties: false, required: ["text", "ref"], - properties: { text: { type: "string" }, ref: { type: "string" } }, + properties: { text: { type: "string" }, ref: { type: "string", pattern: "^r\\d+$" } }, }, }, impl: async (args) => { const a = args as { text: string; ref?: string }; const target = a.ref ? ctx.refs?.get(a.ref) : undefined; if (!target) { - return { success: false, output: "no such ref — pass the [rN] tag from a line you were shown (message tags answer in that thread; the conversation tag posts there)" }; + return { success: false, output: `"${a.ref ?? ""}" is not a ref — copy the [rN] tag (like r3) from the start of a line you were shown; timestamps and channel ids are labels, not addresses` }; } const key = conversationOf(target); const anchor: Anchor = { venueId: key.venueId, threadRootId: key.threadRootId }; @@ -371,7 +371,7 @@ function reactTool(ctx: ToolsetContext): ToolFactory { type: "object", additionalProperties: false, required: ["emoji", "ref"], - properties: { emoji: { type: "string" }, ref: { type: "string" } }, + properties: { emoji: { type: "string" }, ref: { type: "string", pattern: "^r\\d+$" } }, }, }, impl: async (args) => { @@ -803,7 +803,7 @@ function stepBackTool(ctx: ToolsetContext): ToolFactory { type: "object", additionalProperties: false, required: ["why", "ref"], - properties: { why: { type: "string" }, ref: { type: "string" } }, + properties: { why: { type: "string" }, ref: { type: "string", pattern: "^r\\d+$" } }, }, }, impl: async (args) => { diff --git a/test/resident.test.ts b/test/resident.test.ts index 618eda4..294db80 100644 --- a/test/resident.test.ts +++ b/test/resident.test.ts @@ -416,7 +416,7 @@ describe("resident delivery", () => { await service.start(); await service.idle(); // flushes the boot wake carrying both conversations - expect(rejected[0]).toContain("no such ref"); + expect(rejected[0]).toContain("is not a ref"); expect(adapter.posts).toHaveLength(1); expect(adapter.posts[0]!.venueId).toBe("C1"); // where the answer belongs... expect(adapter.posts[0]!.threadRootTs).toBe("1.0"); // ...in ITS thread, not the batch's last diff --git a/test/toolset.test.ts b/test/toolset.test.ts index 81efa85..5834aaf 100644 --- a/test/toolset.test.ts +++ b/test/toolset.test.ts @@ -260,7 +260,7 @@ describe("reply posting-scope rule (SPEC §11) — addressing as refs", () => { expect(JSON.stringify(replyTool.spec.inputSchema)).not.toContain("venueId"); const bare = await replyTool.run({ text: "hi" }); expect(bare.success).toBe(false); - expect(bare.output).toContain("no such ref"); + expect(bare.output).toContain("is not a ref"); const invented = await replyTool.run({ text: "hi", ref: "r99" }); expect(invented.success).toBe(false); const smuggled = await replyTool.run({ text: "hi", ref: "r1", venueId: "C1", threadRootId: "9.9" });