Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions commands/gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,7 @@ export function gateAskOutput(data: Commands["gate:ask"]["data"]): Record<string
return {
ok: true, id: data.id, presentation: data.presentation, subject: data.subject, supersededId: data.supersededId,
...(data.contextOmitted ? { contextOmitted: true } : {}),
...(data.formCapExceeded ? { formCapExceeded: data.formCapExceeded, formCapAdvisory: data.formCapAdvisory } : {}),
};
}

Expand All @@ -221,6 +222,9 @@ export async function gateAsk(args: string[]): Promise<void> {
if (data.contextOmitted) {
console.error(`rt gate: context omitted: gate context plus question contexts exceeded the shared ${CONTEXT_CAP_BYTES}-byte budget; question contexts were dropped, and the gate context too if it was over on its own; shorten and re-ask`);
}
if (data.formCapExceeded) {
console.error(`rt gate: form cap exceeded (${data.formCapExceeded.map((q) => `${q.question}: ${q.options}`).join(", ")}): ${data.formCapAdvisory}`);
}
console.log(JSON.stringify(gateAskOutput(data)));
}

Expand Down
43 changes: 43 additions & 0 deletions lib/daemon/__tests__/gate-ask-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,49 @@ describe("gate:ask context enforcement", () => {
});
});

// RT-202: a caller whose question busts the form's option cap used to get a
// bare "wait" with no reason, which is how option-folding survived across
// every wrapper skill. The response now says why, once, at the seam.
describe("gate:ask form-cap advisory (RT-202)", () => {
const CTX = "the plan section under decision, quoted verbatim";

test("an over-cap question on a formable pane reports which question and why", async () => {
const { handlers } = harness({ resolveSubject: () => ({ ok: true, subject: "mr:x" }) });
const res = await handlers["gate:ask"]({
questions: fiveOptionQuestion(), subject: "mr:x", context: CTX,
sessionId: "sess-1", paneId: "w1:p1",
});
expect(res.ok).toBe(true);
if (!res.ok) return;
expect(res.data.presentation).toBe("wait");
expect(res.data.formCapExceeded).toEqual([{ question: "q1", options: 5 }]);
expect(res.data.formCapAdvisory).toContain("4 options");
expect(res.data.formCapAdvisory).toContain("next");
});

test("with no pane a form was never possible, so no advisory rides the wait", async () => {
const { handlers } = harness({ resolveSubject: () => ({ ok: true, subject: "mr:x" }) });
const res = await handlers["gate:ask"]({ questions: fiveOptionQuestion(), subject: "mr:x", context: CTX });
expect(res.ok).toBe(true);
if (!res.ok) return;
expect(res.data.presentation).toBe("wait");
expect(res.data.formCapExceeded).toBeUndefined();
});

test("questions inside the cap carry no advisory", async () => {
const { handlers } = harness({ resolveSubject: () => ({ ok: true, subject: "mr:x" }) });
const res = await handlers["gate:ask"]({
questions: twoOptionQuestion(), subject: "mr:x", context: CTX,
sessionId: "sess-1", paneId: "w1:p1",
});
expect(res.ok).toBe(true);
if (!res.ok) return;
expect(res.data.presentation).toBe("form");
expect(res.data.formCapExceeded).toBeUndefined();
expect(res.data.formCapAdvisory).toBeUndefined();
});
});

describe("gate:ask structured question context (RT-184)", () => {
const CTX = "the plan section under decision, quoted verbatim";
const withContexts = (a: number, b: number): GateQuestion[] => [
Expand Down
16 changes: 15 additions & 1 deletion lib/daemon/handlers/gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ import { gateHints } from "../gate-push.ts";
import type { Reconciler } from "../reconciler.ts";
import type { AgentRecord } from "../../state/agents-store.ts";
import type { PaneHints } from "../pane-resolve-live.ts";
import { gatePresentation } from "../../../packages/rt-client/src/gate-presentation.ts";
import { GATE_FORM_OPTION_CAP, gatePresentation } from "../../../packages/rt-client/src/gate-presentation.ts";

const FORM_CAP_ADVISORY = `the in-pane form caps a question at ${GATE_FORM_OPTION_CAP} options, so this gate presents as wait; give navigation verbs (iterate, go back, hold) their own next question, and split an oversized selection into <id>-1, <id>-2, ... questions whose answers read as one union`;
import { resolveGateSubject, type GateSubjectResult } from "../gate-subject.ts";

/**
Expand Down Expand Up @@ -750,6 +752,14 @@ export function createGateHandlers(
if (!resolved.ok) return { ok: false as const, error: resolved.error };

const presentation = gatePresentation({ paneId, sessionId, questions });
// Only when a form was otherwise POSSIBLE: a pane-less ask waits for its
// own reason, and an advisory there would teach the wrong lesson. The
// gate still opens either way -- an over-cap wait gate is legitimate
// (remote cards render any option count) -- the caller just learns WHY
// the in-pane form never appeared, which no skill prose reached.
const overCap = paneId && sessionId
? questions.filter((q) => q.options.length > GATE_FORM_OPTION_CAP)
: [];

// Omitted rather than rejected: unlike gate:open's hard CONTEXT_CAP_BYTES
// reject, an oversized context here must not fail the whole ask, since
Expand Down Expand Up @@ -807,6 +817,10 @@ export function createGateHandlers(
data: {
id: opened.data.id, presentation, subject: resolved.subject, supersededId: opened.data.supersededId,
...(contextOmitted ? { contextOmitted: true as const } : {}),
...(overCap.length > 0 ? {
formCapExceeded: overCap.map((q) => ({ question: q.id, options: q.options.length })),
formCapAdvisory: FORM_CAP_ADVISORY,
} : {}),
},
};
};
Expand Down
2 changes: 1 addition & 1 deletion lib/mcp/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,7 @@ export function mcpTools(): McpToolDef[] {
},
{
name: "gate_ask",
description: "Open a decision gate with the daemon-side ceremony: subject resolves from this session (explicit subject wins, else its running run, else its agent record's own subject), presentation is computed, and the operator is nudged. Always pass context, quoted from the material the reader decides on, and never trim or skip it for size: over the shared 8192-byte budget (top-level context plus every question's context), question contexts are dropped server-side first, then the top-level context if it is over on its own, and the drop is reported back as contextOmitted: true. A human-owned gate with no context is refused. Returns {id, presentation, subject, supersededId}; then run `rt gate wait <id>` as background bash and park. The wait itself is never a tool. Prefer {value, label} option objects; bare strings are accepted and stored normalized. Answers must be option VALUES verbatim.",
description: "Open a decision gate with the daemon-side ceremony: subject resolves from this session (explicit subject wins, else its running run, else its agent record's own subject), presentation is computed, and the operator is nudged. Always pass context, quoted from the material the reader decides on, and never trim or skip it for size: over the shared 8192-byte budget (top-level context plus every question's context), question contexts are dropped server-side first, then the top-level context if it is over on its own, and the drop is reported back as contextOmitted: true. A human-owned gate with no context is refused. The in-pane form caps every question at 4 options: keep navigation verbs (iterate, go back, hold) as their own next question and split a larger selection into <id>-1, <id>-2, ... questions whose answers read as one union; one over-cap question makes the whole gate present as wait, reported back as formCapExceeded with the remedy. Returns {id, presentation, subject, supersededId}; then run `rt gate wait <id>` as background bash and park. The wait itself is never a tool. Prefer {value, label} option objects; bare strings are accepted and stored normalized. Answers must be option VALUES verbatim.",
inputSchema: {
type: "object",
properties: {
Expand Down
13 changes: 11 additions & 2 deletions packages/rt-client/src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -721,8 +721,17 @@ export interface Commands {
/** `contextOmitted` appears only when the gate context plus every
question's `context` exceeded their shared 8192-byte budget: the
gate still opened, but question contexts were dropped, and the gate
context too when it was over the budget on its own. */
data: { id: string; presentation: "form" | "wait"; subject: string; supersededId: string | null; contextOmitted?: true };
context too when it was over the budget on its own.
`formCapExceeded`/`formCapAdvisory` appear only when the pane could
have presented an in-pane form and one or more questions exceeded
its 4-option cap, which is the one thing that forced this gate to
`wait`: the gate still opens, and the advisory names the structural
fix so the caller can re-author. */
data: {
id: string; presentation: "form" | "wait"; subject: string; supersededId: string | null; contextOmitted?: true;
formCapExceeded?: Array<{ question: string; options: number }>;
formCapAdvisory?: string;
};
};
/**
* A CAS loss is a DEFINED OUTCOME, not an error: `ok:true` with
Expand Down
Loading