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
8 changes: 4 additions & 4 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -260,10 +260,10 @@ phrase destructive.
**Disclosure.** A caret exists only when there is human-readable detail.
A chip with nothing to disclose offers no control at all. Expanded detail
is quiet inset prose under that chip. Raw JSON, JSON strings, and
model-facing instructions (e.g. ask_user's "do not repeat this in prose")
never reach a reader — parse JSON into a count ("3 results.") or omit the
disclosure. The only exception is code the user actually asked for, which
is prose, not machinery.
model-facing instructions (e.g. request_connection's "keep helping in
the meantime") never reach a reader — parse JSON into a count
("3 results.") or omit the disclosure. The only exception is code the
user actually asked for, which is prose, not machinery.

**Chrome.** Hit area ≥40px via an invisible `::before`. Radius
`--radius`. Motion via `--duration-standard` / `--ease-out`. Scale-on-press
Expand Down
34 changes: 17 additions & 17 deletions VENDORED.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions packages/interaction-tools/package.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{
"name": "@corbits/interaction-tools",
"private": true,
"description": "The ask_user tool: an @intx/agent bundle that poses an interview question in-thread as a question block and returns immediately, letting the answer arrive as the responding user's next message",
"version": "0.0.2",
"description": "The ask_user tool: an @intx/agent bundle that poses an interview question in-thread as a question block and parks the turn on a message_response gate until the user answers",
"version": "0.0.3",
"license": "LGPL-2.1-or-later",
"type": "module",
"exports": {
Expand Down
109 changes: 71 additions & 38 deletions packages/interaction-tools/src/tool.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { expect, test } from "bun:test";
import type { ToolBundle } from "@intx/agent";
import type { ToolCall } from "@intx/types/runtime";

import { ASK_USER_TOOL, interactionTools } from "./tool";
import type { AskUserEnv } from "./tool";
Expand All @@ -24,6 +26,16 @@ async function withFetch<T>(
}
}

function beforeTool(bundle: ToolBundle, call: ToolCall) {
const extension = bundle.beforeToolExtension;
if (extension === undefined) {
throw new Error(
"interactionTools did not contribute a beforeToolExtension",
);
}
return extension.beforeTool(call, {} as never, new AbortController().signal);
}

test("declares exactly ask_user, with no approval gate", () => {
expect(interactionTools.definitions).toEqual([{ name: ASK_USER_TOOL }]);
});
Expand All @@ -36,7 +48,7 @@ test("requires the sanctioned env keys", () => {
]);
});

test("ask_user posts a question block and returns immediately, never the answer", async () => {
test("ask_user posts a question block and suspends on a message_response gate", async () => {
let posted = false;
const fetchImpl = (async () => {
posted = true;
Expand All @@ -47,24 +59,29 @@ test("ask_user posts a question block and returns immediately, never the answer"
}) as unknown as typeof fetch;

const bundle = interactionTools(testEnv());
const result = await withFetch(fetchImpl, () =>
bundle.run(
{
id: "call_1",
name: ASK_USER_TOOL,
arguments: {
question: "Which environment?",
options: ["Staging", "Production"],
},
},
new AbortController().signal,
),
);
const call = {
id: "call_1",
name: ASK_USER_TOOL,
arguments: {
question: "Which environment?",
options: ["Staging", "Production"],
},
};
const before = Date.now();
const decision = await withFetch(fetchImpl, () => beforeTool(bundle, call));

expect(posted).toBe(true);
expect(result.isError).toBe(false);
expect(result.content).not.toContain("Staging");
expect(String(result.content)).toContain("next message");
if (decision.type !== "suspend") {
throw new Error(`expected a suspend decision, got ${decision.type}`);
}
expect(decision.gate.type).toBe("message_response");
expect(decision.gate.timeoutAt).toBeGreaterThan(before);
expect(decision.pendingOp.kind).toBe("message_response");
expect(decision.pendingOp.suspendedCall).toEqual(call);
expect(decision.pendingOp.correlationId).toBe(decision.gate.correlationId);
// Minted by `postQuestion` (`q_<hex32>`), not a separately-minted
// `crypto.randomUUID()` — the gate and the question card share one id.
expect(decision.gate.correlationId).toMatch(/^q_[0-9a-f]{32}$/);
});

test("ask_user rejects fewer than 2 options before ever posting", async () => {
Expand All @@ -75,42 +92,58 @@ test("ask_user rejects fewer than 2 options before ever posting", async () => {
}) as unknown as typeof fetch;

const bundle = interactionTools(testEnv());
const result = await withFetch(fetchImpl, () =>
bundle.run(
{
id: "call_1",
name: ASK_USER_TOOL,
arguments: { question: "Q?", options: ["only one"] },
},
new AbortController().signal,
),
const decision = await withFetch(fetchImpl, () =>
beforeTool(bundle, {
id: "call_1",
name: ASK_USER_TOOL,
arguments: { question: "Q?", options: ["only one"] },
}),
);

expect(posted).toBe(false);
expect(result.isError).toBe(true);
expect(decision.type).toBe("block");
});

test("ask_user surfaces a no-own-channel failure as an error result, not a throw", async () => {
test("ask_user surfaces a no-own-channel failure as a blocked call, not a throw", async () => {
const fetchImpl = (async () =>
new Response(
JSON.stringify({ error: { code: "not_found", message: "no channel" } }),
{ status: 404 },
)) as unknown as typeof fetch;

const bundle = interactionTools(testEnv());
const result = await withFetch(fetchImpl, () =>
bundle.run(
{
id: "call_1",
name: ASK_USER_TOOL,
arguments: { question: "Q?", options: ["a", "b"] },
},
new AbortController().signal,
),
const decision = await withFetch(fetchImpl, () =>
beforeTool(bundle, {
id: "call_1",
name: ASK_USER_TOOL,
arguments: { question: "Q?", options: ["a", "b"] },
}),
);

if (decision.type !== "block") {
throw new Error(`expected a block decision, got ${decision.type}`);
}
expect(decision.reason).toContain("no channel");
});

test("a call for another tool name is allowed through unsuspended", async () => {
const bundle = interactionTools(testEnv());
const decision = await beforeTool(bundle, {
id: "call_1",
name: "some_other_tool",
arguments: {},
});
expect(decision).toEqual({ type: "allow" });
});

test("run() is never the ask_user path: reaching it fails loud", async () => {
const bundle = interactionTools(testEnv());
const result = await bundle.run(
{ id: "call_1", name: ASK_USER_TOOL, arguments: {} },
new AbortController().signal,
);
expect(result.isError).toBe(true);
expect(String(result.content)).toContain("no channel");
expect(String(result.content)).toContain("beforeToolExtension");
});

test("an unknown tool name returns an honest error", async () => {
Expand Down
118 changes: 87 additions & 31 deletions packages/interaction-tools/src/tool.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,31 @@
// The `ask_user` tool: poses an interview question in-thread as an
// interactive `question` block (`@corbits/chat`'s `blocks.ts`) instead of
// prose bullet options, then returns immediately — the answer is never
// awaited synchronously. It arrives as the responding user's own next
// message in this same channel (`packages/chat/src/routes.ts`'s question
// response handling relays it there), so the calling agent reads it on its
// next turn exactly like any other reply. Read-only-ish UI action: no
// approval gate, mirroring `list_agents` rather than `create_agent`.
// prose bullet options, then structurally parks the turn on a
// `message_response` gate — the reactor neither runs nor answers the call
// until a correlated reply arrives. The answer surfaces as the responding
// user's own next message in this same channel
// (`packages/chat/src/routes.ts`'s question response handling relays it
// there), which clears the gate and becomes the call's tool result. No
// synchronous guess: the turn cannot proceed until it does.
import { defineTool } from "@intx/agent";
import type { BaseEnv } from "@intx/agent";
import type { ToolCall, ToolResult } from "@intx/types/runtime";
import type {
BeforeToolDecision,
PendingOperation,
ToolCall,
ToolResult,
} from "@intx/types/runtime";
import { type } from "arktype";

import { postQuestion, NoOwnChannelError } from "./client";
import type { AskUserClientConfig } from "./client";

export const ASK_USER_TOOL = "ask_user";

/** How long a posted question waits for an answer before the gate times out
* and the parked call is answered with a synthetic error. */
export const ASK_USER_TIMEOUT_MS = 3_600_000;

export interface AskUserEnv extends BaseEnv {
readonly hubChatUrl: string;
readonly sidecarToken: string;
Expand Down Expand Up @@ -46,43 +56,74 @@ function clientConfig(env: AskUserEnv): AskUserClientConfig {
};
}

async function runAskUser(
/**
* `ask_user`'s `BeforeToolExtension.beforeTool`: posts the question card,
* then parks the call on a `message_response` gate rather than answering it.
* The reactor registers the gate and durably persists `pendingOp` before
* returning to its loop, so the suspension survives a hub restart; it clears
* only when a correlated reply arrives (or the gate times out).
*/
async function beforeAskUser(
env: AskUserEnv,
call: ToolCall,
): Promise<ToolResult> {
): Promise<BeforeToolDecision> {
if (call.name !== ASK_USER_TOOL) {
return { type: "allow" };
}

const parsed = AskUserInput(call.arguments);
if (parsed instanceof type.errors) {
return errorResult(
call.id,
new Error(`${ASK_USER_TOOL} received invalid input: ${parsed.summary}`),
);
return {
type: "block",
reason: `${ASK_USER_TOOL} received invalid input: ${parsed.summary}`,
};
}

let questionId: string;
try {
await postQuestion(clientConfig(env), parsed);
({ questionId } = await postQuestion(clientConfig(env), parsed));
} catch (err) {
if (err instanceof NoOwnChannelError) {
return errorResult(call.id, err);
return { type: "block", reason: err.message };
}
return errorResult(call.id, err);
return {
type: "block",
reason: err instanceof Error ? err.message : String(err),
};
}

// `postQuestion` already mints `questionId` and stamps it on the outbound
// question card's `data.questionId`; reusing it as the gate's own
// `correlationId` (rather than minting a second, unrelated id) is what
// lets the answer route resolve this exact gate later (CL-7191) — the
// block a person answers is keyed on `blockId`, which for a question
// block IS `questionId` (`packages/chat/src/schema.ts`'s "agent-authored
// pollId/formId" comment applies identically here).
const correlationId = questionId;
const timeoutAt = Date.now() + ASK_USER_TIMEOUT_MS;
const gateId = `pending-${correlationId}`;
const pendingOp: PendingOperation = {
correlationId,
kind: "message_response",
registeredAt: Date.now(),
gateId,
timeoutAt,
suspendedCall: call,
};

return {
callId: call.id,
isError: false,
content:
"The question has been shown to the user as an interactive card. " +
"Do not repeat or restate it in prose. Their answer will arrive as " +
"their next message in this conversation — wait for it rather than " +
"guessing.",
type: "suspend",
gate: { type: "message_response", gateId, correlationId, timeoutAt },
pendingOp,
};
}

/**
* The `@corbits/interaction-tools` bundle factory: one tool, `ask_user`,
* for posing an enumerable-option interview question as an in-thread card
* instead of a prose list. No approval — showing a question is not an
* external side effect.
* instead of a prose list. No approval gate — showing a question is not an
* external side effect — but its own `message_response` gate parks the turn
* until the user answers.
*/
export const interactionTools = defineTool<AskUserEnv>({
id: "@corbits/interaction-tools/ask-user",
Expand All @@ -97,9 +138,9 @@ export const interactionTools = defineTool<AskUserEnv>({
"options, rendered as an interactive card in the conversation " +
"instead of a prose list. Use this whenever interviewing the " +
"user with a small set of enumerable options (2-6), rather " +
"than writing the options out as text. Returns immediately: " +
"the user's answer arrives as their next message, not as this " +
"call's result.",
"than writing the options out as text. Parks the turn until " +
"the user answers: the answer becomes this call's result, not " +
"a separate message to watch for.",
inputSchema: {
type: "object",
properties: {
Expand Down Expand Up @@ -129,18 +170,33 @@ export const interactionTools = defineTool<AskUserEnv>({
},
},
],
beforeToolExtension: {
beforeTool: (call: ToolCall) => beforeAskUser(env, call),
},
run: (call: ToolCall, _signal: AbortSignal) => {
if (call.name !== ASK_USER_TOOL) {
// `beforeToolExtension` above intercepts every ask_user call and parks
// it before dispatch ever reaches here; reaching this arm means
// `interactionTools`'s `beforeToolExtension` was never composed into
// `ResolvedTools.beforeToolExtensions` (`vendor/intx/agent/src/agent.ts`)
// — a re-pin or refactor dropped that wiring — not merely "unreachable".
if (call.name === ASK_USER_TOOL) {
return Promise.resolve(
errorResult(
call.id,
new Error(
`@corbits/interaction-tools: unknown tool "${call.name}"`,
`${ASK_USER_TOOL}'s beforeToolExtension was not composed ` +
"into ResolvedTools.beforeToolExtensions — a re-pin or " +
"refactor dropped the wiring in vendor/intx/agent/src/agent.ts",
),
),
);
}
return runAskUser(env, call);
return Promise.resolve(
errorResult(
call.id,
new Error(`@corbits/interaction-tools: unknown tool "${call.name}"`),
),
);
},
}),
});
8 changes: 4 additions & 4 deletions scripts/checks/kill-dates.txt
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,17 @@
# drift: editing a vendored tree means updating this hash and the
# package's VENDORED-FROM delta line in the same change.
apps/sidecar | sawyer | 2026-10-26
vendor/intx/agent | sawyer | 2026-10-26 | d0d56d9f452b78f4b541ad8f4e89f975e8069446bb98f2c8097b90de4b020243
vendor/intx/agent | sawyer | 2026-10-26 | 2264716cfaa126ef3205f4f9887f3a580072ab8552d6bbc351bd8ca1638bfd74
vendor/intx/db | sawyer | 2026-10-26 | 0a4cdb9a8a6ff19d5d4713cbc4f5cc9257aad839b1fa393b2026e6d5afd828b9
vendor/intx/harness | sawyer | 2026-10-26 | 867f2b0eb4a360c68bf552d5b95a9530411e2c1a2d046d1f5ce718d4a9be36a8
vendor/intx/hub-agent | sawyer | 2026-10-26 | 30d5050511f22bc73b3c5d34728dfdf5b791de203452b83d4333a1dc762afceb
vendor/intx/hub-api | sawyer | 2026-10-26 | 42ee33e027559b236065382cb94f393bbcfee69625615894f82be778f34f7aa1
vendor/intx/hub-sessions | sawyer | 2026-10-26 | 53addc3090ad9f54bc4bac8fb50ad8d567ccf46f30bb5403d447351cb16b4fb6
vendor/intx/inference | sawyer | 2026-10-26 | 77fec29b078e8d03e686747c70e6b62ac1fd1434db0fb2c1e12e84b6dc71465f
vendor/intx/hub-sessions | sawyer | 2026-10-26 | 19fcc2c0bf6af1dd9a1b267513bea777968c454a323cc402fc5ec1cca8097523
vendor/intx/inference | sawyer | 2026-10-26 | f32792856f555b0fed40ce75badde7630a45a6452ad20cfc61058192f8376f11
vendor/intx/mail-memory | sawyer | 2026-10-26 | 9f3601a7fb22e2d1c63daa976f3afccbd79af2187c155a0080c0d60c82450b92
vendor/intx/mailbox | sawyer | 2026-10-26 | d36d7ffcc32018571276e4922a8c2714b7ee0bb5deb80b01e73859245975d4c6
vendor/intx/mime | sawyer | 2026-10-26 | d02e5f8f1429eac7c27d3a37eec31111f8a1053c91fbfae77ac58a0d63c823ed
vendor/intx/types | sawyer | 2026-10-26 | ec1de14b859007b4db137da1533d4ce79d11024ad69c8b36938017319d6d8e86
vendor/intx/types | sawyer | 2026-10-26 | beef009a180cb7747c65014ed38c732a124851c81f60029ccd0c33075896b93f
vendor/intx/workflow | sawyer | 2026-10-26 | 4b51b9bd6a124cfaa0c916e2b26c04ac9170618bb092f0c8e1c312263fc84fdf
vendor/intx/workflow-deploy | sawyer | 2026-10-26 | 960a2ae408223649fe8be0e3b9d63f2b0cca25259bc0ae06761bca521bb738e5
vendor/intx/workflow-host | sawyer | 2026-10-26 | aff0342a526387ea9ccb52827fd4f13d9dd52645b37795362ce9b2fd912a5da5
Expand Down
2 changes: 1 addition & 1 deletion vendor/intx/agent/VENDORED-FROM
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
Source: https://github.com/faremeter/interchange (packages/agent)
Commit: a8bc06ae38661c5e0ed91ded8559bf09f502213d (origin/main, 2026-08-27)
License: LGPL-2.1-only (see vendor/intx/LICENSE)
Local modifications: exports map repointed from the upstream intx-src condition to direct TypeScript source resolution (types/default -> ./src/...); dist references removed.
Local modifications: exports map repointed from the upstream intx-src condition to direct TypeScript source resolution (types/default -> ./src/...); dist references removed. CL-7190: ToolBundle.beforeToolExtension (tool.ts) and its composition into ResolvedTools.beforeToolExtensions (agent.ts), so a tool package can contribute its own suspend-capable extension without the reactor or director special-casing it by name.
Loading
Loading