Skip to content
Draft
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
7 changes: 7 additions & 0 deletions .changeset/thinking-control-content-type.md
Original file line number Diff line number Diff line change
@@ -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`.
73 changes: 73 additions & 0 deletions src/commands/agent/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 =
Expand All @@ -218,6 +230,7 @@ export type AgentCommand =
| CapabilityRequestCommand
| CloudConnectionGrantRequestCommand
| ThinkingCommand
| ThinkingControlCommand
| StopCommand;

export default class AgentServe extends ConvosBaseCommand {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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");
Expand Down
10 changes: 10 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions src/utils/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down
142 changes: 142 additions & 0 deletions src/utils/thinkingControl.ts
Original file line number Diff line number Diff line change
@@ -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": "<message id the thinking session anchors to>",
* "agentInboxId": "<inbox id of the agent whose session is targeted>"
* }
*
* 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<ThinkingControl>;
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<ThinkingControl> {
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;
}
Loading
Loading