diff --git a/.changeset/thinking-control-content-type.md b/.changeset/thinking-control-content-type.md new file mode 100644 index 0000000..7a6329d --- /dev/null +++ b/.changeset/thinking-control-content-type.md @@ -0,0 +1,7 @@ +--- +"@xmtp/convos-cli": minor +--- + +Add the thinking-control content type (`convos.org/thinking-control:1.0`), the user-to-agent counterpart of the thinking content type. Where `convos.org/thinking:1.0` is an agent narrating its own thinking session, a thinking-control message is any conversation member asking the agent to stop that session or resume it. The JSON payload carries an `action` (`stop` or `resume`), the `targetMessageId` the session anchors to, and the `agentInboxId` of the agent being addressed, so the request stays unambiguous when several agents think about the same message. Like thinking, it is silent: no fallback text, `shouldPush` false, and never rendered as a chat row. + +Controls are requests rather than state transitions - the agent acknowledges by emitting its own thinking events. `agent serve` now decodes inbound thinking-control messages into a `thinking_control` event (with `action`, `targetMessageId`, `agentInboxId`) and accepts a `thinking-control` stdin command for sending them. Exports: `ThinkingControlCodec`, `ContentTypeThinkingControl`, `isThinkingControlMessage`, `getThinkingControlContent`, and the `ThinkingControl` / `ThinkingControlAction` types, from both the package root and `@xmtp/convos-cli/utils/thinkingControl`. diff --git a/src/commands/agent/serve.ts b/src/commands/agent/serve.ts index f20b087..ceec68e 100644 --- a/src/commands/agent/serve.ts +++ b/src/commands/agent/serve.ts @@ -88,6 +88,12 @@ import { isThinkingMessage, type Thinking, } from "../../utils/thinking.js"; +import { + ThinkingControlCodec, + getThinkingControlContent, + isThinkingControlMessage, + type ThinkingControl, +} from "../../utils/thinkingControl.js"; import { randomUUID } from "node:crypto"; import { ConnectionInvocationCodec, @@ -200,6 +206,12 @@ interface ThinkingCommand { /** Optional — only meaningful on `stop`. The agent's reply that closed the thinking. */ resultMessageId?: string; } +interface ThinkingControlCommand { + type: "thinking-control"; + action: "stop" | "resume"; + targetMessageId: string; + agentInboxId: string; +} interface StopCommand { type: "stop"; } export type AgentCommand = @@ -218,6 +230,7 @@ export type AgentCommand = | CapabilityRequestCommand | CloudConnectionGrantRequestCommand | ThinkingCommand + | ThinkingControlCommand | StopCommand; export default class AgentServe extends ConvosBaseCommand { @@ -437,6 +450,7 @@ STDERR: QR code, diagnostic logs (does not interfere with protocol)`; if (isCapabilityRequestMessage(message)) return "capability_request" as const; if (isCapabilityRequestResultMessage(message)) return "capability_result" as const; if (isThinkingMessage(message)) return "thinking" as const; + if (isThinkingControlMessage(message)) return "thinking_control" as const; return undefined; })(); if (!handled) return false; @@ -650,6 +664,24 @@ STDERR: QR code, diagnostic logs (does not interfere with protocol)`; return true; } + if (handled === "thinking_control") { + const control = getThinkingControlContent(message); + if (control) { + this.emit({ + event: "thinking_control", + id: message.id, + senderInboxId: message.senderInboxId, + conversationId: conversation.id, + action: control.action, + targetMessageId: control.targetMessageId, + agentInboxId: control.agentInboxId, + sentAt: message.sentAt.toISOString(), + ...(catchup && { catchup: true }), + }); + } + return true; + } + return false; } @@ -1642,6 +1674,47 @@ STDERR: QR code, diagnostic logs (does not interfere with protocol)`; break; } + case "thinking-control": { + if (cmd.action !== "stop" && cmd.action !== "resume") { + this.emitError( + "thinking-control command requires 'action' to be 'stop' or 'resume'", + ); + return; + } + if (typeof cmd.targetMessageId !== "string" || cmd.targetMessageId.length === 0) { + this.emitError( + "thinking-control command requires non-empty 'targetMessageId' field", + ); + return; + } + if (typeof cmd.agentInboxId !== "string" || cmd.agentInboxId.length === 0) { + this.emitError( + "thinking-control command requires non-empty 'agentInboxId' field", + ); + return; + } + const control: ThinkingControl = { + action: cmd.action, + targetMessageId: cmd.targetMessageId, + agentInboxId: cmd.agentInboxId, + }; + const codec = new ThinkingControlCodec(); + const encoded = codec.encode(control); + const controlMessageId = await conversation.send(encoded); + + this.emit({ + event: "sent", + id: controlMessageId, + type: "thinking-control", + action: control.action, + targetMessageId: control.targetMessageId, + agentInboxId: control.agentInboxId, + conversationId: conversation.id, + timestamp: new Date().toISOString(), + }); + break; + } + case "connection-invoke": { if (!cmd.kind) { this.emitError("connection-invoke command requires 'kind' field"); diff --git a/src/index.ts b/src/index.ts index 2191352..ba5549f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -97,6 +97,16 @@ export { type ThinkingState, } from "./utils/thinking.js"; +// Thinking-control content type +export { + ThinkingControlCodec, + ContentTypeThinkingControl, + isThinkingControlMessage, + getThinkingControlContent, + type ThinkingControl, + type ThinkingControlAction, +} from "./utils/thinkingControl.js"; + // Explode settings content type export { ExplodeSettingsCodec, diff --git a/src/utils/client.ts b/src/utils/client.ts index 9a32223..56a2f95 100644 --- a/src/utils/client.ts +++ b/src/utils/client.ts @@ -20,6 +20,7 @@ import { CapabilityRequestCodec } from "./capabilityRequest.js"; import { CapabilityRequestResultCodec } from "./capabilityRequestResult.js"; import { CloudConnectionGrantRequestCodec } from "./cloudConnectionGrantRequest.js"; import { ThinkingCodec } from "./thinking.js"; +import { ThinkingControlCodec } from "./thinkingControl.js"; import { toHexBytes, hexToBytes } from "./xmtp.js"; import { privateKeyToAccount } from "viem/accounts"; import type { ConvosConfig } from "./config.js"; @@ -151,6 +152,7 @@ async function buildClient( new CapabilityRequestResultCodec() as any, new CloudConnectionGrantRequestCodec() as any, new ThinkingCodec() as any, + new ThinkingControlCodec() as any, ], dbEncryptionKey: toHexBytes(identity.dbEncryptionKey), dbPath, diff --git a/src/utils/thinkingControl.ts b/src/utils/thinkingControl.ts new file mode 100644 index 0000000..90b2c55 --- /dev/null +++ b/src/utils/thinkingControl.ts @@ -0,0 +1,142 @@ +/** + * Thinking-control content type for Convos. + * + * User → conversation "stop (or resume) that agent's thinking session" + * request. The counterpart to the Thinking content type: where + * `convos.org/thinking:1.0` is the agent narrating its own session, this + * type is any conversation member asking the agent to halt or pick the + * session back up. Silent, no fallback, JSON payload, filtered from chat + * history. + * + * Wire shape: + * { + * "action": "stop" | "resume", + * "targetMessageId": "", + * "agentInboxId": "" + * } + * + * A thinking session is keyed by `(agentInboxId, targetMessageId)` — the + * same key the Thinking events use (there the agent is the sender, so the + * agent inbox id is implicit). `agentInboxId` keeps the request unambiguous + * when two agents think about the same message. + * + * These are requests, not state transitions: the agent acknowledges by + * emitting its own Thinking events (a `stop` control is answered with a + * thinking `stop`, a `resume` with a fresh thinking `start`). A control + * for an anchor with no active session is a quiet no-op. + */ + +import type { ContentTypeId, EncodedContent } from "@xmtp/node-bindings"; +import type { ContentCodec } from "@xmtp/content-type-primitives"; +import type { DecodedMessage } from "@xmtp/node-sdk"; + +// ─── Content Type ─── + +export const ContentTypeThinkingControl: ContentTypeId = { + authorityId: "convos.org", + typeId: "thinking-control", + versionMajor: 1, + versionMinor: 0, +}; + +// ─── Types ─── + +export type ThinkingControlAction = "stop" | "resume"; + +export interface ThinkingControl { + action: ThinkingControlAction; + targetMessageId: string; + agentInboxId: string; +} + +// ─── Codec ─── + +function validate(value: unknown): asserts value is ThinkingControl { + if (!value || typeof value !== "object") { + throw new Error("Invalid ThinkingControl payload"); + } + const t = value as Partial; + if (t.action !== "stop" && t.action !== "resume") { + throw new Error("Invalid ThinkingControl.action"); + } + if (typeof t.targetMessageId !== "string" || t.targetMessageId.length === 0) { + throw new Error("Missing ThinkingControl.targetMessageId"); + } + if (typeof t.agentInboxId !== "string" || t.agentInboxId.length === 0) { + throw new Error("Missing ThinkingControl.agentInboxId"); + } +} + +export class ThinkingControlCodec implements ContentCodec { + get contentType(): ContentTypeId { + return ContentTypeThinkingControl; + } + + encode(content: ThinkingControl): EncodedContent { + validate(content); + return { + type: ContentTypeThinkingControl, + parameters: {}, + content: new TextEncoder().encode(JSON.stringify(content)), + } as EncodedContent; + } + + decode(content: EncodedContent): ThinkingControl { + const json = new TextDecoder().decode(content.content); + const parsed = JSON.parse(json) as unknown; + validate(parsed); + return parsed; + } + + fallback(_content: ThinkingControl): string | undefined { + return undefined; + } + + shouldPush(_content: ThinkingControl): boolean { + return false; + } +} + +// ─── Helpers ─── + +export function isThinkingControlMessage(message: DecodedMessage): boolean { + const ct = message.contentType; + return ( + ct.authorityId === ContentTypeThinkingControl.authorityId && + ct.typeId === ContentTypeThinkingControl.typeId + ); +} + +export function getThinkingControlContent( + message: DecodedMessage, +): ThinkingControl | undefined { + const content = message.content; + if (!content || typeof content !== "object") return undefined; + + if ( + "action" in content && + "targetMessageId" in content && + "agentInboxId" in content + ) { + try { + validate(content); + return content as ThinkingControl; + } catch { + return undefined; + } + } + + if ("content" in content && (content as any).content instanceof Uint8Array) { + try { + const parsed = JSON.parse( + new TextDecoder().decode((content as any).content), + ) as unknown; + validate(parsed); + return parsed; + } catch { + return undefined; + } + } + + return undefined; +} diff --git a/test/utils/thinkingControl.test.ts b/test/utils/thinkingControl.test.ts new file mode 100644 index 0000000..7b13e66 --- /dev/null +++ b/test/utils/thinkingControl.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from "vitest"; +import { + ContentTypeThinkingControl, + ThinkingControlCodec, + getThinkingControlContent, + isThinkingControlMessage, + type ThinkingControl, +} from "../../src/utils/thinkingControl.js"; + +const codec = new ThinkingControlCodec(); + +function mockMessage(contentType: any, content?: any) { + return { contentType, content } as any; +} + +function make(overrides: Partial = {}): ThinkingControl { + return { + action: "stop", + targetMessageId: "msg-123", + agentInboxId: "agent-inbox-456", + ...overrides, + }; +} + +describe("ContentTypeThinkingControl", () => { + it("matches the documented authority/type/version", () => { + expect(ContentTypeThinkingControl).toEqual({ + authorityId: "convos.org", + typeId: "thinking-control", + versionMajor: 1, + versionMinor: 0, + }); + }); +}); + +describe("ThinkingControlCodec", () => { + it("round-trips a stop action", () => { + const original = make(); + expect(codec.decode(codec.encode(original))).toEqual(original); + }); + + it("round-trips a resume action", () => { + const original = make({ action: "resume" }); + expect(codec.decode(codec.encode(original))).toEqual(original); + }); + + it("rejects invalid action on encode", () => { + expect(() => codec.encode(make({ action: "pause" as any }))).toThrow(/action/); + }); + + it("rejects empty targetMessageId on encode", () => { + expect(() => codec.encode(make({ targetMessageId: "" }))).toThrow(/targetMessageId/); + }); + + it("rejects empty agentInboxId on encode", () => { + expect(() => codec.encode(make({ agentInboxId: "" }))).toThrow(/agentInboxId/); + }); + + it("rejects missing fields on decode", () => { + const bad = { + type: ContentTypeThinkingControl, + parameters: {}, + content: new TextEncoder().encode( + JSON.stringify({ action: "stop", targetMessageId: "msg-1" }), + ), + } as any; + expect(() => codec.decode(bad)).toThrow(/agentInboxId/); + }); + + it("rejects unknown action on decode", () => { + const bad = { + type: ContentTypeThinkingControl, + parameters: {}, + content: new TextEncoder().encode( + JSON.stringify({ action: "wat", targetMessageId: "x", agentInboxId: "y" }), + ), + } as any; + expect(() => codec.decode(bad)).toThrow(/action/); + }); + + it("is silent and has no fallback", () => { + expect(codec.shouldPush(make())).toBe(false); + expect(codec.fallback(make())).toBeUndefined(); + }); +}); + +describe("isThinkingControlMessage", () => { + it("returns true for matching content type", () => { + expect(isThinkingControlMessage(mockMessage(ContentTypeThinkingControl))).toBe(true); + }); + it("returns false for other types", () => { + expect( + isThinkingControlMessage( + mockMessage({ authorityId: "convos.org", typeId: "thinking", versionMajor: 1, versionMinor: 0 }), + ), + ).toBe(false); + }); +}); + +describe("getThinkingControlContent", () => { + it("returns decoded content directly", () => { + const control = make(); + expect( + getThinkingControlContent(mockMessage(ContentTypeThinkingControl, control)), + ).toEqual(control); + }); + + it("decodes raw EncodedContent fallback", () => { + const raw = { + content: new TextEncoder().encode(JSON.stringify(make())), + }; + expect( + getThinkingControlContent(mockMessage(ContentTypeThinkingControl, raw)), + ).toEqual(make()); + }); + + it("returns undefined for invalid content", () => { + expect( + getThinkingControlContent(mockMessage(ContentTypeThinkingControl, { foo: "bar" })), + ).toBeUndefined(); + expect( + getThinkingControlContent(mockMessage(ContentTypeThinkingControl, null)), + ).toBeUndefined(); + }); + + it("returns undefined when raw bytes are malformed", () => { + const raw = { + content: new TextEncoder().encode("not json"), + }; + expect( + getThinkingControlContent(mockMessage(ContentTypeThinkingControl, raw)), + ).toBeUndefined(); + }); +});