Auto-reply: ack think directives

main
Peter Steinberger 2025-12-03 08:54:38 +00:00
parent 5a83a44112
commit 48dfb1c8ca
4 changed files with 105 additions and 7 deletions

View File

@ -4,6 +4,7 @@
### Highlights ### Highlights
- **Thinking directives & state:** `/t|/think|/thinking <level>` (aliases off|minimal|low|medium|high|max/highest). Inline applies to that message; directive-only message pins the level for the session; `/think:off` clears. Resolution: inline > session override > `inbound.reply.thinkingDefault` > off. Pi/Tau get `--thinking <level>` (except off); other agents append cue words (`think` → `think hard``think harder``ultrathink`). Heartbeat probe uses `HEARTBEAT /think:high`. - **Thinking directives & state:** `/t|/think|/thinking <level>` (aliases off|minimal|low|medium|high|max/highest). Inline applies to that message; directive-only message pins the level for the session; `/think:off` clears. Resolution: inline > session override > `inbound.reply.thinkingDefault` > off. Pi/Tau get `--thinking <level>` (except off); other agents append cue words (`think` → `think hard``think harder``ultrathink`). Heartbeat probe uses `HEARTBEAT /think:high`.
- **Directive confirmations:** Directive-only messages now reply with an acknowledgement (`Thinking level set to high.` / `Thinking disabled.`) and reject unknown levels with a helpful hint (state is unchanged).
- **Pi/Tau stability:** RPC replies buffered until the assistant turn finishes; parsers return consistent `texts[]`; web auto-replies keep a warm Tau RPC process to avoid cold starts. - **Pi/Tau stability:** RPC replies buffered until the assistant turn finishes; parsers return consistent `texts[]`; web auto-replies keep a warm Tau RPC process to avoid cold starts.
- **Claude prompt flow:** One-time `sessionIntro` with per-message `/think:high` bodyPrefix; system prompt always sent on first turn even with `sendSystemOnce`. - **Claude prompt flow:** One-time `sessionIntro` with per-message `/think:high` bodyPrefix; system prompt always sent on first turn even with `sendSystemOnce`.
- **Heartbeat UX:** Backpressure skips reply heartbeats while other commands run; skips dont refresh session `updatedAt`; web/Twilio heartbeats normalize array payloads and optional `heartbeatCommand`. - **Heartbeat UX:** Backpressure skips reply heartbeats while other commands run; skips dont refresh session `updatedAt`; web/Twilio heartbeats normalize array payloads and optional `heartbeatCommand`.

View File

@ -18,6 +18,7 @@
## Setting a session default ## Setting a session default
- Send a message that is **only** the directive (whitespace allowed), e.g. `/think:medium` or `/t high`. - Send a message that is **only** the directive (whitespace allowed), e.g. `/think:medium` or `/t high`.
- That sticks for the current session (per-sender by default); cleared by `/think:off` or session idle reset. - That sticks for the current session (per-sender by default); cleared by `/think:off` or session idle reset.
- Confirmation reply is sent (`Thinking level set to high.` / `Thinking disabled.`). If the level is invalid (e.g. `/thinking big`), the command is rejected with a hint and the session state is left unchanged.
## Application by agent ## Application by agent
- **Pi/Tau**: injects `--thinking <level>` (skipped for `off`). - **Pi/Tau**: injects `--thinking <level>` (skipped for `off`).

View File

@ -53,8 +53,10 @@ function normalizeThinkLevel(raw?: string | null): ThinkLevel | undefined {
function extractThinkDirective(body?: string): { function extractThinkDirective(body?: string): {
cleaned: string; cleaned: string;
thinkLevel?: ThinkLevel; thinkLevel?: ThinkLevel;
rawLevel?: string;
hasDirective: boolean;
} { } {
if (!body) return { cleaned: "" }; if (!body) return { cleaned: "", hasDirective: false };
// Match the longest keyword first to avoid partial captures (e.g. "/think:high") // Match the longest keyword first to avoid partial captures (e.g. "/think:high")
const match = body.match( const match = body.match(
/\/(?:thinking|think|t)\s*:?\s*([a-zA-Z-]+)\b/i, /\/(?:thinking|think|t)\s*:?\s*([a-zA-Z-]+)\b/i,
@ -63,7 +65,12 @@ function extractThinkDirective(body?: string): {
const cleaned = match const cleaned = match
? body.replace(match[0], "").replace(/\s+/g, " ").trim() ? body.replace(match[0], "").replace(/\s+/g, " ").trim()
: body.trim(); : body.trim();
return { cleaned, thinkLevel }; return {
cleaned,
thinkLevel,
rawLevel: match?.[1],
hasDirective: !!match,
};
} }
function isAbortTrigger(text?: string): boolean { function isAbortTrigger(text?: string): boolean {
@ -203,9 +210,12 @@ export async function getReplyFromConfig(
IsNewSession: isNewSession ? "true" : "false", IsNewSession: isNewSession ? "true" : "false",
}; };
const { cleaned: thinkCleaned, thinkLevel: inlineThink } = extractThinkDirective( const {
sessionCtx.BodyStripped ?? sessionCtx.Body ?? "", cleaned: thinkCleaned,
); thinkLevel: inlineThink,
rawLevel: rawThinkLevel,
hasDirective: hasThinkDirective,
} = extractThinkDirective(sessionCtx.BodyStripped ?? sessionCtx.Body ?? "");
sessionCtx.Body = thinkCleaned; sessionCtx.Body = thinkCleaned;
sessionCtx.BodyStripped = thinkCleaned; sessionCtx.BodyStripped = thinkCleaned;
@ -215,7 +225,13 @@ export async function getReplyFromConfig(
(reply?.thinkingDefault as ThinkLevel | undefined); (reply?.thinkingDefault as ThinkLevel | undefined);
// Directive-only message => persist session thinking level and return ack // Directive-only message => persist session thinking level and return ack
if (inlineThink && !thinkCleaned) { if (hasThinkDirective && !thinkCleaned) {
if (!inlineThink) {
cleanupTyping();
return {
text: `Unrecognized thinking level "${rawThinkLevel ?? ""}". Valid levels: off, minimal, low, medium, high.`,
};
}
if (sessionEntry && sessionStore && sessionKey) { if (sessionEntry && sessionStore && sessionKey) {
if (inlineThink === "off") { if (inlineThink === "off") {
delete sessionEntry.thinkingLevel; delete sessionEntry.thinkingLevel;
@ -226,8 +242,12 @@ export async function getReplyFromConfig(
sessionStore[sessionKey] = sessionEntry; sessionStore[sessionKey] = sessionEntry;
await saveSessionStore(storePath, sessionStore); await saveSessionStore(storePath, sessionStore);
} }
const ack =
inlineThink === "off"
? "Thinking disabled."
: `Thinking level set to ${inlineThink}.`;
cleanupTyping(); cleanupTyping();
return { text: `Thinking level set to ${inlineThink}` }; return { text: ack };
} }
// Optional allowlist by origin number (E.164 without whatsapp: prefix) // Optional allowlist by origin number (E.164 without whatsapp: prefix)

View File

@ -612,6 +612,82 @@ describe("config and templating", () => {
expect(args.join(" ")).toContain("hi there think harder"); expect(args.join(" ")).toContain("hi there think harder");
}); });
it("confirms directive-only think level and skips command", async () => {
const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({
stdout: "ok",
stderr: "",
code: 0,
signal: null,
killed: false,
});
const cfg = {
inbound: {
reply: {
mode: "command" as const,
command: ["echo", "{{Body}}"],
agent: { kind: "claude" },
},
},
};
const ack = await index.getReplyFromConfig(
{ Body: "/thinking high", From: "+1", To: "+2" },
undefined,
cfg,
runSpy,
);
expect(runSpy).not.toHaveBeenCalled();
expect(ack?.text).toBe("Thinking level set to high.");
});
it("rejects invalid directive-only think level without changing state", async () => {
const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({
stdout: "ok",
stderr: "",
code: 0,
signal: null,
killed: false,
});
const storeDir = await fs.promises.mkdtemp(
path.join(os.tmpdir(), "warelay-session-"),
);
const storePath = path.join(storeDir, "sessions.json");
const cfg = {
inbound: {
reply: {
mode: "command" as const,
command: ["echo", "{{Body}}"],
agent: { kind: "claude" },
session: { store: storePath },
},
},
};
const ack = await index.getReplyFromConfig(
{ Body: "/thinking big", From: "+1", To: "+2" },
undefined,
cfg,
runSpy,
);
expect(runSpy).not.toHaveBeenCalled();
expect(ack?.text).toContain("Unrecognized thinking level \"big\"");
// Send another message; state should not carry any level.
const second = await index.getReplyFromConfig(
{ Body: "hi", From: "+1", To: "+2" },
undefined,
cfg,
runSpy,
);
expect(runSpy).toHaveBeenCalledTimes(1);
const args = runSpy.mock.calls[0][0] as string[];
const bodyArg = args[args.length - 1];
expect(bodyArg).toBe("hi");
expect(second?.text).toBe("ok");
});
it("uses global thinkingDefault when no directive or session override", async () => { it("uses global thinkingDefault when no directive or session override", async () => {
const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({ const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({
stdout: "ok", stdout: "ok",