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
85 changes: 59 additions & 26 deletions src/inbound/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ import {
import type { InkboxRuntime, PluginLogger } from "../client.js";
import type { ResolvedInkboxAccount } from "../accounts.js";
import { recordInboundChannelHint } from "../channel-hint.js";
import {
buildSpawnContextBlock,
consumeSpawnLink,
normalizeRecipientKey,
setActiveConversation,
} from "../spawn-context.js";
import {
classifySendRejection,
claimDeliveryFailure,
Expand Down Expand Up @@ -1940,11 +1946,21 @@ async function dispatchInboundTurn(
const baseSessionKey = route.sessionKey;
const effectiveSessionKey =
opts.turn.mode === "voice" ? voiceSessionKey(route.agentId, opts.turn) : baseSessionKey;
// A spun-off thread: this reply is from someone the agent messaged during
// another conversation. Inherit that parent so the turn knows why it exists
// and can relay the answer back — see spawn-context.ts.
const isMessagingMode =
opts.turn.mode === "sms" || opts.turn.mode === "imessage" || opts.turn.mode === "email";
const spawnLink = isMessagingMode ? consumeSpawnLink(opts.turn.remoteAddress) : undefined;
const spawnContextBlock = spawnLink ? buildSpawnContextBlock(spawnLink) : undefined;
const agentBody = spawnContextBlock ? `${spawnContextBlock}\n${opts.turn.body}` : opts.turn.body;
const modelParentSessionKey =
opts.turn.mode === "voice" ? baseSessionKey : spawnLink?.parentSessionKey;
const { storePath, body } = buildEnvelope({
channel: "Inkbox",
from: opts.turn.fromLabel,
timestamp,
body: opts.turn.body,
body: agentBody,
});
const conversationPrefix = opts.turn.mode === "imessage" ? "imessage" : "sms";
const smsReplyTarget = opts.turn.conversationId
Expand Down Expand Up @@ -1975,7 +1991,7 @@ async function dispatchInboundTurn(
agentId: route.agentId,
accountId: routeAccountId,
routeSessionKey: effectiveSessionKey,
...(opts.turn.mode === "voice" ? { modelParentSessionKey: baseSessionKey } : {}),
...(modelParentSessionKey ? { modelParentSessionKey } : {}),
},
reply: {
to:
Expand All @@ -1999,9 +2015,9 @@ async function dispatchInboundTurn(
},
message: {
body,
bodyForAgent: opts.turn.body,
rawBody: opts.turn.body,
commandBody: opts.turn.body,
bodyForAgent: agentBody,
rawBody: agentBody,
commandBody: agentBody,
envelopeFrom: opts.turn.fromLabel,
},
extra: {
Expand Down Expand Up @@ -2087,28 +2103,45 @@ async function dispatchInboundTurn(
);
},
};
await core.inbound.dispatchReply({
cfg: opts.cfg as any,
channel: "inkbox",
accountId: opts.account.accountId,
agentId: route.agentId,
routeSessionKey: effectiveSessionKey,
storePath,
ctxPayload,
recordInboundSession: core.session.recordInboundSession,
dispatchReplyWithBufferedBlockDispatcher:
core.reply.dispatchReplyWithBufferedBlockDispatcher,
...(replyOptions ? { replyOptions } : {}),
delivery,
replyPipeline: {},
record: {
onRecordError: (error: unknown) => {
opts.logger?.warn?.(
`Inkbox session record failed: ${error instanceof Error ? error.message : String(error)}`,
);
// Mark this conversation active while the agent runs, so a send tool it
// invokes to a new recipient parents the spin-off back to here. Only real
// messaging turns can be relayed back to; voice/warmup/external cannot.
if (isMessagingMode) {
setActiveConversation({
sessionKey: baseSessionKey,
replyTarget: smsReplyTarget,
label: opts.turn.conversationLabel ?? opts.turn.fromLabel,
party: normalizeRecipientKey(opts.turn.remoteAddress),
});
}
try {
await core.inbound.dispatchReply({
cfg: opts.cfg as any,
channel: "inkbox",
accountId: opts.account.accountId,
agentId: route.agentId,
routeSessionKey: effectiveSessionKey,
storePath,
ctxPayload,
recordInboundSession: core.session.recordInboundSession,
dispatchReplyWithBufferedBlockDispatcher:
core.reply.dispatchReplyWithBufferedBlockDispatcher,
...(replyOptions ? { replyOptions } : {}),
delivery,
replyPipeline: {},
record: {
onRecordError: (error: unknown) => {
opts.logger?.warn?.(
`Inkbox session record failed: ${error instanceof Error ? error.message : String(error)}`,
);
},
},
},
});
});
} finally {
if (isMessagingMode) {
setActiveConversation(undefined);
}
}
}

const REALTIME_CONSULT_TIMED_OUT = Symbol("realtime-consult-timed-out");
Expand Down
143 changes: 143 additions & 0 deletions src/spawn-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
// Spin-off thread context (issue #10).
//
// When the agent messages someone new mid-conversation ("go ask Alex about
// X"), the send goes out through a send tool and Alex's eventual reply routes
// to a fresh session with no link back to the conversation that spawned it.
//
// This is the same in-process seam as channel-hint.ts: the inbound bridge
// records the conversation it is currently processing, the send tools read
// that to parent a spin-off, and the inbound bridge consumes the link when
// the new recipient replies — so the spawned turn knows why it exists, holds
// a reference to its parent thread, and can relay the answer back.

// The conversation currently being processed. Set by the inbound bridge for
// the duration of a turn's agent run (same global-latest tradeoff as
// channel-hint.ts): a send tool invoked during the run reads this to know
// which conversation it is spinning off from.
export interface ActiveConversation {
// Session key of the conversation the agent is currently in.
sessionKey: string;
// Where a relayed answer goes back (conversation id or address).
replyTarget: string;
// Human label of the parent conversation, for the injected context block.
label: string;
// The parent's remote party, normalized — used to tell a spin-off (a new
// recipient) apart from a reply to the same person.
party?: string;
}

let active: ActiveConversation | undefined;

export function setActiveConversation(ctx: ActiveConversation | undefined): void {
active = ctx;
}

export function getActiveConversation(): ActiveConversation | undefined {
return active;
}

// A recorded spin-off: the new recipient's reply should inherit this parent.
export interface SpawnLink {
parentSessionKey: string;
parentReplyTarget: string;
parentLabel: string;
// The message the agent sent out, as the "why" the thread exists.
why: string;
}

type StoredSpawnLink = SpawnLink & { recordedAt: number };

// A spin-off waiting on a reply is short-lived; bound by TTL and count so an
// abandoned outreach can't linger and mis-parent a much later, unrelated
// conversation with the same person.
const SPAWN_LINK_TTL_MS = 6 * 60 * 60 * 1000;
const SPAWN_LINK_MAX_ENTRIES = 500;
const WHY_SNIPPET_MAX_CHARS = 500;
const spawnLinks = new Map<string, StoredSpawnLink>();

// Normalize a recipient into the same key an inbound reply will resolve to:
// email addresses lowercased, phone numbers reduced to their +digits.
export function normalizeRecipientKey(value: string | undefined): string | undefined {
const trimmed = value?.trim();
if (!trimmed) {
return undefined;
}
if (trimmed.includes("@")) {
return trimmed.toLowerCase();
}
const digits = trimmed.replace(/[^+\d]/g, "");
return digits || trimmed;
}

function prune(now = Date.now()): void {
for (const [key, link] of spawnLinks) {
if (now - link.recordedAt > SPAWN_LINK_TTL_MS) {
spawnLinks.delete(key);
}
}
while (spawnLinks.size > SPAWN_LINK_MAX_ENTRIES) {
const oldest = spawnLinks.keys().next().value as string | undefined;
if (oldest === undefined) break;
spawnLinks.delete(oldest);
}
}

// Record that the currently-active conversation spun off a message to
// `recipient`. No-op when there is no active conversation, or when the
// recipient is the active conversation's own party (a reply, not a spin-off).
export function recordSpawnFromActive(params: {
recipient: string | undefined;
body: string | undefined;
}): void {
const parent = active;
if (!parent) {
return;
}
const key = normalizeRecipientKey(params.recipient);
if (!key || key === parent.party) {
return;
}
prune();
spawnLinks.set(key, {
parentSessionKey: parent.sessionKey,
parentReplyTarget: parent.replyTarget,
parentLabel: parent.label,
why: (params.body ?? "").slice(0, WHY_SNIPPET_MAX_CHARS),
recordedAt: Date.now(),
});
}

// Consume the spawn link for a replying recipient, if any. One-shot: the
// first reply inherits the parent (context block + parent session), after
// which the spawned session carries the history itself.
export function consumeSpawnLink(recipient: string | undefined): SpawnLink | undefined {
prune();
const key = normalizeRecipientKey(recipient);
if (!key) {
return undefined;
}
const link = spawnLinks.get(key);
if (!link) {
return undefined;
}
spawnLinks.delete(key);
const { recordedAt: _recordedAt, ...rest } = link;
return rest;
}

// The context block prepended to a spun-off reply's turn: what the thread is
// for, and how to relay the answer back to the parent conversation.
export function buildSpawnContextBlock(link: SpawnLink): string {
return [
`[inkbox:spinoff_thread parent=${JSON.stringify(link.parentLabel)} relay_to=${link.parentReplyTarget}]`,
`You started this conversation from another thread (${link.parentLabel}).`,
`You reached out here to: ${JSON.stringify(link.why)}`,
"When you have what you need, relay the answer back to that original conversation using your messaging tools; do not leave it waiting.",
].join("\n");
}

// Test hook — the module-level stores persist across vitest cases otherwise.
export function resetSpawnContextForTest(): void {
active = undefined;
spawnLinks.clear();
}
6 changes: 6 additions & 0 deletions src/tools/send-email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Type } from "typebox";
import type { InkboxRuntime } from "../client.js";
import { runTool, toolText, toolError } from "../errors.js";
import { checkOutboundRecipients } from "../allowlist.js";
import { recordSpawnFromActive } from "../spawn-context.js";

// Outbound email — the primary write path for the email channel.
export function registerSendEmail(
Expand Down Expand Up @@ -51,6 +52,11 @@ export function registerSendEmail(
bcc: params.bcc,
inReplyToMessageId: params.inReplyToMessageId,
});
// A single-recipient email to a new address mid-conversation is a
// spin-off; link it back so the reply inherits the parent thread.
if (Array.isArray(params.to) && params.to.length === 1) {
recordSpawnFromActive({ recipient: params.to[0], body: params.bodyText ?? params.bodyHtml });
}
return toolText(
`Sent email id=${msg.id} to=${params.to.join(",")} subject="${params.subject}"`,
);
Expand Down
6 changes: 6 additions & 0 deletions src/tools/send-imessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Type } from "typebox";
import type { InkboxRuntime } from "../client.js";
import { runTool, toolText, toolError } from "../errors.js";
import { checkOutboundRecipient } from "../allowlist.js";
import { recordSpawnFromActive } from "../spawn-context.js";
import { IMESSAGE_MAX_TEXT_CHARS, imessageTextTooLongMessage } from "../message-limits.js";

// Outbound iMessage — recipient-first channel: a person must have connected
Expand Down Expand Up @@ -97,6 +98,11 @@ export function registerSendIMessage(
...(mediaUrls?.length ? { mediaUrls } : {}),
...(params.sendStyle ? { sendStyle: params.sendStyle } : {}),
});
// A send addressed to a new number (not an existing conversation) is a
// spin-off; link it back so the reply inherits the parent thread.
if (to) {
recordSpawnFromActive({ recipient: to, body: text || undefined });
}
const target = conversationId ? `conversation=${conversationId}` : `to=${to}`;
return toolText(
`Sent iMessage id=${msg.id} ${target} conversation_id=${msg.conversationId} status=${msg.status ?? "unknown"}`,
Expand Down
6 changes: 6 additions & 0 deletions src/tools/send-sms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Type } from "typebox";
import type { InkboxRuntime } from "../client.js";
import { runTool, toolText, toolError } from "../errors.js";
import { checkOutboundRecipient } from "../allowlist.js";
import { recordSpawnFromActive } from "../spawn-context.js";
import { SMS_MAX_TEXT_CHARS, smsTextTooLongMessage } from "../message-limits.js";

function normalizeRecipients(value: unknown): string[] | undefined {
Expand Down Expand Up @@ -121,6 +122,11 @@ export function registerSendSms(
: { to: toList!.length === 1 ? toList![0] : toList }),
};
const msg = await identity.sendText(payload);
// A 1:1 send to a new number mid-conversation is a spin-off; link it
// back so the recipient's reply inherits the parent thread.
if (!hasConversation && toList && toList.length === 1) {
recordSpawnFromActive({ recipient: toList[0], body: params.text });
}
const target = formatTargetSummary(msg, params);
const status = msg.deliveryStatus ?? "unknown";
return toolText(
Expand Down
Loading
Loading