From 6f1204830e9cb81eda7c4b2028d095143d09205f Mon Sep 17 00:00:00 2001 From: Ricky Schema Cascade Date: Fri, 24 Jul 2026 10:31:35 +0200 Subject: [PATCH 1/5] feat: add reusable multi-turn agent kit --- .github/workflows/publish.yml | 4 +- .github/workflows/verify-publish.yml | 1 + README.md | 3 + examples/tsconfig.json | 3 +- examples/turn-agent/README.md | 16 ++ examples/turn-agent/agent.ts | 66 ++++++ examples/turn-agent/persona.ts | 18 ++ packages/turn-kit/CHANGELOG.md | 18 ++ packages/turn-kit/README.md | 140 +++++++++++ packages/turn-kit/package.json | 40 ++++ packages/turn-kit/src/actions.ts | 35 +++ packages/turn-kit/src/context.ts | 60 +++++ packages/turn-kit/src/index.ts | 37 +++ packages/turn-kit/src/memory.ts | 140 +++++++++++ packages/turn-kit/src/persona.ts | 35 +++ packages/turn-kit/src/runner.ts | 136 +++++++++++ packages/turn-kit/src/turn-kit.test.ts | 313 +++++++++++++++++++++++++ packages/turn-kit/src/types.ts | 147 ++++++++++++ packages/turn-kit/tsconfig.json | 8 + pnpm-lock.yaml | 9 + 20 files changed, 1227 insertions(+), 2 deletions(-) create mode 100644 examples/turn-agent/README.md create mode 100644 examples/turn-agent/agent.ts create mode 100644 examples/turn-agent/persona.ts create mode 100644 packages/turn-kit/CHANGELOG.md create mode 100644 packages/turn-kit/README.md create mode 100644 packages/turn-kit/package.json create mode 100644 packages/turn-kit/src/actions.ts create mode 100644 packages/turn-kit/src/context.ts create mode 100644 packages/turn-kit/src/index.ts create mode 100644 packages/turn-kit/src/memory.ts create mode 100644 packages/turn-kit/src/persona.ts create mode 100644 packages/turn-kit/src/runner.ts create mode 100644 packages/turn-kit/src/turn-kit.test.ts create mode 100644 packages/turn-kit/src/types.ts create mode 100644 packages/turn-kit/tsconfig.json diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index b1e6b81c..7870d581 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -94,13 +94,14 @@ jobs: # workload-router (→ persona-kit; consumed by cli) # deploy (→ persona-kit + runtime; consumed by cli) # review-kit (→ persona-kit + runtime; authoring infrastructure) + # turn-kit (→ persona-kit + runtime; multi-turn agent infrastructure) # mcp-workforce (→ persona-kit + runtime) # daytona-runner (no workspace deps) # local-surface (→ deploy + persona-kit + runtime; consumed by cli) # cli (→ persona-kit + workload-router + deploy + local-surface) # agentworkforce (→ cli — umbrella wrapper, must publish last) # personas-core publishes via the separate publish-personas.yml workflow. - echo "packages=events persona-kit runtime compose delivery workload-router deploy review-kit mcp-workforce daytona-runner local-surface cli agentworkforce" >> "$GITHUB_OUTPUT" + echo "packages=events persona-kit runtime compose delivery workload-router deploy review-kit turn-kit mcp-workforce daytona-runner local-surface cli agentworkforce" >> "$GITHUB_OUTPUT" # Lockstep baseline heal. The workspace publishes every package at the # same version, so if any package's local version lags either its own @@ -717,6 +718,7 @@ jobs: 'delivery', 'workload-router', 'deploy', + 'turn-kit', 'mcp-workforce', 'daytona-runner', 'cli', diff --git a/.github/workflows/verify-publish.yml b/.github/workflows/verify-publish.yml index 1fda5860..917a536f 100644 --- a/.github/workflows/verify-publish.yml +++ b/.github/workflows/verify-publish.yml @@ -22,6 +22,7 @@ on: - '@agentworkforce/workload-router' - '@agentworkforce/deploy' - '@agentworkforce/review-kit' + - '@agentworkforce/turn-kit' - '@agentworkforce/mcp-workforce' - '@agentworkforce/daytona-runner' - '@agentworkforce/local-surface' diff --git a/README.md b/README.md index c99f1fc1..7dd382f0 100644 --- a/README.md +++ b/README.md @@ -317,6 +317,9 @@ grammar, skill staging, and sandbox mount behavior live in - `packages/runtime` — deploy runtime facade and per-integration clients. - `packages/review-kit` — strongly typed factories and evidence providers for charter-driven pull-request reviewers. +- `packages/turn-kit` — transport-neutral multi-turn lifecycle, chronological + memory, deterministic context providers, acknowledgements, and + receipt-gated final delivery. - `packages/deploy` — bundle staging and runner launch modes for `workforce deploy`. diff --git a/examples/tsconfig.json b/examples/tsconfig.json index 0881d50d..7637790e 100644 --- a/examples/tsconfig.json +++ b/examples/tsconfig.json @@ -8,7 +8,8 @@ "@agentworkforce/runtime/runner": ["../packages/runtime/src/runner.ts"], "@agentworkforce/runtime/clients": ["../packages/runtime/src/clients/index.ts"], "@agentworkforce/runtime/raw": ["../packages/runtime/src/raw.ts"], - "@agentworkforce/persona-kit": ["../packages/persona-kit/src/index.ts"] + "@agentworkforce/persona-kit": ["../packages/persona-kit/src/index.ts"], + "@agentworkforce/turn-kit": ["../packages/turn-kit/src/index.ts"] } }, "include": ["./**/*.ts"], diff --git a/examples/turn-agent/README.md b/examples/turn-agent/README.md new file mode 100644 index 00000000..02150a25 --- /dev/null +++ b/examples/turn-agent/README.md @@ -0,0 +1,16 @@ +# turn-kit multi-turn agent + +Minimal runnable Relay channel agent demonstrating +`@agentworkforce/turn-kit`. + +The persona explicitly enables workspace memory. The handler derives a stable +conversation id from the Relay channel + thread, receives chronological +history, sends one direct-model reply, requires a Relay delivery receipt, and +only then saves the turn. + +```bash +agentworkforce deploy ./examples/turn-agent/persona.ts --mode cloud +``` + +Real agents can add deterministic `defineTurnContext()` providers and interim +`acknowledge()` messages without changing that lifecycle. diff --git a/examples/turn-agent/agent.ts b/examples/turn-agent/agent.ts new file mode 100644 index 00000000..ee5ee4ce --- /dev/null +++ b/examples/turn-agent/agent.ts @@ -0,0 +1,66 @@ +import { + defineAgent, + isRelaycastMessageEvent, + type AgentEvent +} from '@agentworkforce/runtime'; +import { + conversationKey, + createTurnRunner +} from '@agentworkforce/turn-kit'; + +const turns = createTurnRunner({ + namespace: 'turn-agent-example', + memory: { + query: 'recent turn-agent-example conversation', + limit: 8, + ttlSeconds: 30 * 24 * 60 * 60, + assistantLabel: 'turn-agent-example' + } +}); + +export default defineAgent({ + handler: async (ctx, rawEvent) => { + const event = rawEvent as unknown as AgentEvent; + if (!isRelaycastMessageEvent(event) || !event.channel) return; + const expanded = await event.expand('full'); + const input = messageText(expanded.data); + if (!input) return; + + await turns.run(ctx, { + conversation: { + transport: 'relay', + id: conversationKey(event.channel, event.threadId) + }, + input, + respond: async ({ history }) => + ctx.llm.complete( + [ + 'Answer clearly in at most four short lines.', + history.length + ? `Conversation so far (oldest first):\n${history.map((entry) => entry.content).join('\n')}` + : '', + `User: ${input}` + ].filter(Boolean).join('\n\n'), + { maxTokens: 300 } + ), + deliver: (reply) => ctx.relay.post(event.channel!, reply), + confirmDelivery: (receipt) => receipt.ok + }); + } +}); + +function messageText(value: unknown): string { + if (!value || typeof value !== 'object' || Array.isArray(value)) return ''; + const record = value as Record; + const nested = + record.message && typeof record.message === 'object' && !Array.isArray(record.message) + ? record.message as Record + : {}; + return ( + typeof record.text === 'string' + ? record.text + : typeof nested.text === 'string' + ? nested.text + : '' + ).trim(); +} diff --git a/examples/turn-agent/persona.ts b/examples/turn-agent/persona.ts new file mode 100644 index 00000000..c7c8a7f9 --- /dev/null +++ b/examples/turn-agent/persona.ts @@ -0,0 +1,18 @@ +import { defineTurnPersona } from '@agentworkforce/turn-kit'; + +export default defineTurnPersona({ + id: 'turn-agent-example', + intent: 'relay-orchestrator', + tags: ['discovery'], + description: 'Minimal multi-turn Relay chat agent built with turn-kit.', + cloud: true, + sandbox: false, + useSubscription: true, + harness: 'claude', + model: 'claude-haiku-4-5-20251001', + systemPrompt: 'Answer clearly and briefly, using the supplied conversation history.', + harnessSettings: { reasoning: 'low', timeoutSeconds: 300 }, + memory: { enabled: true, scopes: ['workspace'], ttlDays: 30 }, + relay: { inbox: ['@self'] }, + onEvent: './agent.ts' +}); diff --git a/packages/turn-kit/CHANGELOG.md b/packages/turn-kit/CHANGELOG.md new file mode 100644 index 00000000..3d626a85 --- /dev/null +++ b/packages/turn-kit/CHANGELOG.md @@ -0,0 +1,18 @@ +# Changelog + +All notable changes to `@agentworkforce/turn-kit` will be documented in this +file. + +## [Unreleased] + +### Added + +- Add a transport-neutral turn runner with chronological conversation memory, + deterministic context providers, best-effort interim acknowledgements, + delivery confirmation, and post-delivery persistence. +- Add standalone conversation identity, recall, and persistence helpers for + handlers that need only part of the lifecycle. +- Add a pass-through persona factory that requires durable memory and expose + the bundled kit version in runtime logs. +- Add a provider-agnostic confirmed-action helper that constructs success + output only after the caller's receipt predicate passes. diff --git a/packages/turn-kit/README.md b/packages/turn-kit/README.md new file mode 100644 index 00000000..f841c74c --- /dev/null +++ b/packages/turn-kit/README.md @@ -0,0 +1,140 @@ +# @agentworkforce/turn-kit + +Transport-neutral lifecycle primitives for multi-turn cloud agents. The kit +owns conversation identity, chronological memory, deterministic context +collection, interim acknowledgements, delivery confirmation, and post-delivery +persistence. The agent keeps control of event parsing, prompts, domain actions, +provider writes, and transport adapters. + +```ts +import { + conversationKey, + createTurnRunner, + defineTurnContext, + defineTurnPersona +} from '@agentworkforce/turn-kit'; + +const turns = createTurnRunner({ + namespace: 'my-agent', + memory: { + query: 'recent my-agent conversation', + limit: 8, + ttlSeconds: 30 * 24 * 60 * 60, + assistantLabel: 'my-agent' + }, + context: [ + defineTurnContext({ + name: 'open-work', + collect: async ({ ctx }) => ({ + title: 'Open work', + content: await ctx.files.read('/my-agent/open-work.json') + }) + }) + ] +}); + +await turns.run(ctx, { + conversation: { + transport: 'telegram', + id: conversationKey(message.chatId, message.threadId) + }, + input: message.text, + acknowledge: (text) => telegram.reply(message, text), + respond: async ({ input, history, context, acknowledge }) => { + await acknowledge('Checking…'); + return ctx.llm.complete(buildPrompt({ input, history, context })); + }, + deliver: (reply) => telegram.reply(message, reply), + confirmDelivery: (receipt) => receipt.ok +}); +``` + +Provider mutations can use `runConfirmedTurnAction()` so user-visible success +text is not even constructed until the receipt predicate passes: + +```ts +const line = await runConfirmedTurnAction({ + ctx, + name: 'create-github-task', + perform: () => tasks.create(input), + confirm: (result) => result.receipt.status === 'succeeded', + confirmed: (result) => `📝 Tracked: ${result.title}` +}); +``` + +Use `defineTurnPersona()` for the persona side. It is a pass-through factory +that requires enabled memory while leaving transports and every other persona +choice to the application: + +```ts +export default defineTurnPersona({ + id: 'my-agent', + intent: 'relay-orchestrator', + description: 'A multi-turn assistant.', + cloud: true, + memory: { enabled: true, scopes: ['workspace'], ttlDays: 30 }, + harnessSettings: { reasoning: 'low', timeoutSeconds: 300 }, + onEvent: './agent.ts' +}); +``` + +## Lifecycle and guarantees + +`createTurnRunner()` executes one turn in this order: + +1. Recall only the matching transport + conversation tag and normalize history + to oldest-first. +2. Run deterministic context providers. Required providers fail closed; + optional providers warn and disappear. +3. Call the agent's responder. It may send best-effort, read-only + acknowledgements for slow work. +4. Deliver the final reply and validate its receipt when + `confirmDelivery` is supplied. +5. Save the user/reply pair only after confirmed delivery. + +A final-delivery failure is never written into conversation history. A memory +save that returns no receipt is logged as `turn-kit.memory-save-unconfirmed` +and surfaced as `memorySaved: false`. + +## What stays outside the kit + +The following boundaries are deliberate: + +- **Transport parsing and loop guards.** Slack, Telegram, relay inbox, and + future surfaces have different envelopes and threading rules. Normalize them + before calling the runner. +- **Domain actions.** GitHub issue writes, Linear mutations, reminders, and + other side effects must wait for their provider receipt. Build success text + from confirmed action results, then return it from `respond`. + `runConfirmedTurnAction()` encodes this ordering without knowing the + provider's receipt shape. +- **Exact operational or pending state.** Semantic memory is suitable for + recent dialogue, not locks, idempotency, ordinals, or workflow state. Store + those in a deterministic provider-backed/file record and expose them through + a context provider. +- **Grounding policy.** The kit guarantees deterministic providers run before + synthesis; each agent decides which sources are required and how to reconcile + them. + +These boundaries match the useful commonality across current agents: +life-agent's per-Telegram conversation and slow lookup acknowledgements, +joke-bot's callback memory, and hn-monitor's exact digest/thread grounding plus +live hydration. HN's exact state remains outside memory by design. + +## Conversation identity + +Use the transport's stable conversation boundary: + +- Telegram: chat id plus forum-topic id. +- Slack: channel id plus root thread timestamp. +- Relay inbox: sender plus any application thread/correlation id. + +`conversationKey(root, thread)` composes the transport-local id, and +`conversationTag(namespace, conversation)` adds namespace and transport +isolation. + +## Partial adoption + +Handlers do not have to use the full runner. `recallTurnHistory()`, +`rememberTurn()`, `collectTurnContext()`, and the conversation helpers are +public for agents with a custom lifecycle. diff --git a/packages/turn-kit/package.json b/packages/turn-kit/package.json new file mode 100644 index 00000000..7e8cdc38 --- /dev/null +++ b/packages/turn-kit/package.json @@ -0,0 +1,40 @@ +{ + "name": "@agentworkforce/turn-kit", + "version": "4.1.34", + "private": false, + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "dist", + "README.md", + "CHANGELOG.md", + "package.json" + ], + "repository": { + "type": "git", + "url": "https://github.com/AgentWorkforce/workforce", + "directory": "packages/turn-kit" + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "dev": "tsc -p tsconfig.json --watch --preserveWatchOutput", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "tsc -p tsconfig.json && node --test dist/*.test.js", + "lint": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@agentworkforce/persona-kit": "workspace:*", + "@agentworkforce/runtime": "workspace:*" + } +} diff --git a/packages/turn-kit/src/actions.ts b/packages/turn-kit/src/actions.ts new file mode 100644 index 00000000..7974a10b --- /dev/null +++ b/packages/turn-kit/src/actions.ts @@ -0,0 +1,35 @@ +import type { WorkforceCtx } from '@agentworkforce/runtime'; + +export interface ConfirmedTurnActionOptions { + ctx?: WorkforceCtx; + /** Stable, non-sensitive action name for logs and errors. */ + name: string; + perform(): Receipt | Promise; + confirm(receipt: Receipt): boolean; + /** + * Construct the user-visible success value only after confirmation. This + * prevents optimistic "Done" text from escaping before a provider receipt. + */ + confirmed(receipt: Receipt): Result; +} + +export class UnconfirmedTurnActionError extends Error { + constructor(action: string) { + super(`turn action "${action}" returned an unconfirmed provider receipt`); + this.name = 'UnconfirmedTurnActionError'; + } +} + +export async function runConfirmedTurnAction( + options: ConfirmedTurnActionOptions +): Promise { + const name = options.name.trim(); + if (!name) throw new TypeError('turn action name is required'); + const receipt = await options.perform(); + if (!options.confirm(receipt)) { + options.ctx?.log?.('error', 'turn-kit.action-unconfirmed', { action: name }); + throw new UnconfirmedTurnActionError(name); + } + options.ctx?.log?.('info', 'turn-kit.action-confirmed', { action: name }); + return options.confirmed(receipt); +} diff --git a/packages/turn-kit/src/context.ts b/packages/turn-kit/src/context.ts new file mode 100644 index 00000000..fa9c4723 --- /dev/null +++ b/packages/turn-kit/src/context.ts @@ -0,0 +1,60 @@ +import type { + TurnContext, + TurnContextProvider, + TurnContextProviderArgs +} from './types.js'; + +/** Identity helper that preserves a custom provider's literal name in its type. */ +export function defineTurnContext( + provider: TurnContextProvider +): TurnContextProvider { + if (!provider.name.trim()) throw new TypeError('turn context provider name is required'); + return provider; +} + +export async function collectTurnContext( + providers: readonly TurnContextProvider[], + args: TurnContextProviderArgs +): Promise { + const collected = await Promise.all( + providers.map(async (provider): Promise => { + try { + const result = await provider.collect(args); + return normalizeContext(result, provider.name); + } catch (error) { + args.ctx.log?.( + provider.optional ? 'warn' : 'error', + 'turn-kit.context-provider-failed', + { provider: provider.name, optional: Boolean(provider.optional), error: String(error) } + ); + if (!provider.optional) throw error; + return []; + } + }) + ); + const context = collected.flat(); + args.ctx.log?.('info', 'turn-kit.context-collected', { + providers: providers.length, + sections: context.length + }); + return context; +} + +function normalizeContext( + value: TurnContext | readonly TurnContext[] | null | undefined, + provider: string +): TurnContext[] { + const entries = value === null || value === undefined + ? [] + : Array.isArray(value) + ? value + : [value]; + return entries.map((entry) => { + const title = entry.title.trim(); + const content = entry.content.trim(); + if (!title || !content) { + throw new TypeError(`turn context provider ${provider} returned an empty section`); + } + return { title, content }; + }); +} diff --git a/packages/turn-kit/src/index.ts b/packages/turn-kit/src/index.ts new file mode 100644 index 00000000..3c61ed12 --- /dev/null +++ b/packages/turn-kit/src/index.ts @@ -0,0 +1,37 @@ +export { + runConfirmedTurnAction, + UnconfirmedTurnActionError, + type ConfirmedTurnActionOptions +} from './actions.js'; +export { + collectTurnContext, + defineTurnContext +} from './context.js'; +export { + conversationKey, + conversationTag, + normalizeTurnHistory, + recallTurnHistory, + rememberTurn +} from './memory.js'; +export { defineTurnPersona } from './persona.js'; +export { + createTurnRunner, + TURN_KIT_VERSION, + UnconfirmedTurnDeliveryError +} from './runner.js'; +export type { + TurnContext, + TurnContextProvider, + TurnContextProviderArgs, + TurnConversation, + TurnHistoryEntry, + TurnMemoryOptions, + TurnRecallOrder, + TurnRequest, + TurnResponderArgs, + TurnResponse, + TurnRunResult, + TurnRunner, + TurnRunnerOptions +} from './types.js'; diff --git a/packages/turn-kit/src/memory.ts b/packages/turn-kit/src/memory.ts new file mode 100644 index 00000000..d0694591 --- /dev/null +++ b/packages/turn-kit/src/memory.ts @@ -0,0 +1,140 @@ +import type { WorkforceCtx } from '@agentworkforce/runtime'; +import type { + TurnConversation, + TurnHistoryEntry, + TurnMemoryOptions +} from './types.js'; + +const DEFAULT_LIMIT = 8; +const DEFAULT_SCOPE = 'workspace'; + +export function conversationKey(root: string, thread?: string | number): string { + const base = requiredIdentityPart(root, 'conversation root'); + if (thread === undefined || thread === null || String(thread).trim() === '') return base; + return `${base}:${requiredIdentityPart(String(thread), 'conversation thread')}`; +} + +export function conversationTag(namespace: string, conversation: TurnConversation): string { + const slug = validNamespace(namespace); + const transport = requiredIdentityPart(conversation.transport, 'conversation transport'); + const id = requiredIdentityPart(conversation.id, 'conversation id'); + return `turn:${slug}:${encodeURIComponent(transport)}:${encodeURIComponent(id)}`; +} + +export async function recallTurnHistory( + ctx: WorkforceCtx, + tag: string, + options: TurnMemoryOptions = {} +): Promise { + const limit = positiveInteger(options.limit ?? DEFAULT_LIMIT, 'turn memory limit'); + let recalled: unknown; + try { + recalled = await ctx.memory.recall(options.query ?? 'recent conversation turns', { + tags: [tag], + limit, + scope: options.scope ?? DEFAULT_SCOPE + }); + } catch (error) { + ctx.log?.('warn', 'turn-kit.memory-recall-failed', { error: String(error) }); + if (options.required) throw error; + return []; + } + + const entries = normalizeTurnHistory(recalled, options.recallOrder ?? 'newest-first'); + ctx.log?.('info', 'turn-kit.memory-recalled', { entries: entries.length }); + return entries; +} + +export function normalizeTurnHistory( + recalled: unknown, + recallOrder: NonNullable = 'newest-first' +): TurnHistoryEntry[] { + if (!Array.isArray(recalled)) return []; + const entries = recalled + .map((item): TurnHistoryEntry | null => { + if (typeof item === 'string') { + const content = item.trim(); + return content ? { content } : null; + } + if (!item || typeof item !== 'object' || Array.isArray(item)) return null; + const record = item as Record; + if (typeof record.content !== 'string' || !record.content.trim()) return null; + return { + content: record.content, + ...(typeof record.createdAt === 'string' && record.createdAt.trim() + ? { createdAt: record.createdAt } + : {}) + }; + }) + .filter((entry): entry is TurnHistoryEntry => entry !== null); + + if (entries.length > 0 && entries.every((entry) => validDate(entry.createdAt))) { + entries.sort((left, right) => + String(left.createdAt).localeCompare(String(right.createdAt)) + ); + } else if (recallOrder === 'newest-first') { + entries.reverse(); + } + return entries; +} + +export async function rememberTurn( + ctx: WorkforceCtx, + tag: string, + input: string, + reply: string, + options: TurnMemoryOptions = {}, + content?: string +): Promise { + const serialized = ( + content ?? + options.serialize?.(input, reply) ?? + `${options.userLabel ?? 'User'}: ${input}\n${options.assistantLabel ?? 'Assistant'}: ${reply}` + ).trim(); + if (!serialized) throw new TypeError('turn memory content cannot be empty'); + + let receipt: { id: string } | void; + try { + receipt = await ctx.memory.save(serialized, { + tags: [tag], + scope: options.scope ?? DEFAULT_SCOPE, + ...(options.ttlSeconds !== undefined ? { ttlSeconds: options.ttlSeconds } : {}) + }); + } catch (error) { + ctx.log?.('warn', 'turn-kit.memory-save-failed', { error: String(error) }); + if (options.required) throw error; + return false; + } + if (!receipt?.id) { + ctx.log?.('warn', 'turn-kit.memory-save-unconfirmed'); + if (options.required) { + throw new Error('turn-kit memory save returned no receipt'); + } + return false; + } + ctx.log?.('info', 'turn-kit.memory-saved', { id: receipt.id }); + return true; +} + +export function validNamespace(namespace: string): string { + const value = namespace.trim(); + if (!/^[a-z0-9](?:[a-z0-9-]{0,62})$/u.test(value)) { + throw new TypeError('turn namespace must be a lowercase slug'); + } + return value; +} + +function requiredIdentityPart(value: string, label: string): string { + const normalized = value.trim(); + if (!normalized) throw new TypeError(`${label} cannot be empty`); + return normalized; +} + +function positiveInteger(value: number, label: string): number { + if (!Number.isInteger(value) || value <= 0) throw new TypeError(`${label} must be positive`); + return value; +} + +function validDate(value: string | undefined): boolean { + return typeof value === 'string' && Number.isFinite(Date.parse(value)); +} diff --git a/packages/turn-kit/src/persona.ts b/packages/turn-kit/src/persona.ts new file mode 100644 index 00000000..cc88c030 --- /dev/null +++ b/packages/turn-kit/src/persona.ts @@ -0,0 +1,35 @@ +import { + definePersona, + type PersonaDefinition +} from '@agentworkforce/persona-kit'; + +type MemoryEnabledPersona = PersonaDefinition & { + memory: true | { + enabled?: true; + scopes?: Array<'workspace' | 'user' | 'global'>; + ttlDays?: number; + [key: string]: unknown; + }; +}; + +/** + * Pass-through persona factory that makes durable conversation memory an + * explicit authoring requirement. It intentionally leaves integrations, + * triggers, model, sandbox, and transport capabilities to the application. + */ +export function defineTurnPersona(input: T): T { + const memory = input.memory as true | { + enabled?: boolean; + scopes?: unknown[]; + }; + if ( + memory !== true && + ( + memory.enabled === false || + (memory.scopes !== undefined && memory.scopes.length === 0) + ) + ) { + throw new TypeError('turn persona requires enabled memory with at least one scope'); + } + return definePersona(input); +} diff --git a/packages/turn-kit/src/runner.ts b/packages/turn-kit/src/runner.ts new file mode 100644 index 00000000..9d470552 --- /dev/null +++ b/packages/turn-kit/src/runner.ts @@ -0,0 +1,136 @@ +import type { WorkforceCtx } from '@agentworkforce/runtime'; +import packageJson from '../package.json' with { type: 'json' }; +import { collectTurnContext } from './context.js'; +import { + conversationTag, + recallTurnHistory, + rememberTurn, + validNamespace +} from './memory.js'; +import type { + TurnRequest, + TurnResponse, + TurnRunResult, + TurnRunner, + TurnRunnerOptions +} from './types.js'; + +/** Read at bundle/install time so deployed logs expose the bundled kit version. */ +export const TURN_KIT_VERSION = packageJson.version; + +export class UnconfirmedTurnDeliveryError extends Error { + constructor(namespace: string) { + super(`${namespace} final turn delivery returned an unconfirmed receipt`); + this.name = 'UnconfirmedTurnDeliveryError'; + } +} + +/** + * Compose the portable lifecycle shared by conversational agents: + * recall → deterministic context → respond/ack → confirmed final delivery → + * remember. Transport parsing, domain actions, and exact state stay outside. + */ +export function createTurnRunner( + options: TurnRunnerOptions +): TurnRunner { + const namespace = validNamespace(options.namespace); + const memory = options.memory === false ? false : options.memory ?? {}; + const providers = [...(options.context ?? [])]; + + return { + async run( + ctx: WorkforceCtx, + request: TurnRequest + ): Promise> { + const input = request.input.trim(); + if (!input) throw new TypeError('turn input cannot be empty'); + const tag = conversationTag(namespace, request.conversation); + ctx.log?.('info', 'turn-kit.started', { + version: TURN_KIT_VERSION, + namespace, + transport: request.conversation.transport + }); + + const history = memory === false + ? [] + : await recallTurnHistory(ctx, tag, memory); + const context = await collectTurnContext(providers, { + ctx, + conversation: request.conversation, + input, + history + }); + + let acknowledgements = 0; + const acknowledge = async (message: string): Promise => { + const text = message.trim(); + if (!text || !request.acknowledge) return false; + try { + await request.acknowledge(text); + acknowledgements += 1; + ctx.log?.('info', 'turn-kit.acknowledged', { namespace }); + return true; + } catch (error) { + ctx.log?.('warn', 'turn-kit.acknowledgement-failed', { + namespace, + error: String(error) + }); + return false; + } + }; + + const response = normalizeResponse( + await request.respond({ + ctx, + conversation: request.conversation, + input, + history, + context, + acknowledge + }) + ); + + const receipt = await request.deliver(response.reply); + if (request.confirmDelivery && !request.confirmDelivery(receipt)) { + ctx.log?.('error', 'turn-kit.delivery-unconfirmed', { namespace }); + throw new UnconfirmedTurnDeliveryError(namespace); + } + + const memorySaved = + memory !== false && response.remember !== false + ? await rememberTurn( + ctx, + tag, + input, + response.reply, + memory, + typeof response.remember === 'string' ? response.remember : undefined + ) + : false; + + ctx.log?.('info', 'turn-kit.completed', { + namespace, + acknowledgements, + context: context.length, + memorySaved + }); + return { + reply: response.reply, + receipt, + context, + acknowledgements, + memorySaved + }; + } + }; +} + +function normalizeResponse(value: string | TurnResponse): TurnResponse { + const response = typeof value === 'string' ? { reply: value } : value; + const reply = response.reply.trim(); + if (!reply) throw new TypeError('turn response cannot be empty'); + if (typeof response.remember === 'string' && !response.remember.trim()) { + throw new TypeError('explicit turn memory cannot be empty'); + } + return { ...response, reply }; +} diff --git a/packages/turn-kit/src/turn-kit.test.ts b/packages/turn-kit/src/turn-kit.test.ts new file mode 100644 index 00000000..9b70255c --- /dev/null +++ b/packages/turn-kit/src/turn-kit.test.ts @@ -0,0 +1,313 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { + MemoryItem, + WorkforceCtx +} from '@agentworkforce/runtime'; +import packageJson from '../package.json' with { type: 'json' }; +import { + conversationKey, + conversationTag, + createTurnRunner, + defineTurnContext, + defineTurnPersona, + normalizeTurnHistory, + runConfirmedTurnAction, + TURN_KIT_VERSION, + UnconfirmedTurnActionError, + UnconfirmedTurnDeliveryError +} from './index.js'; + +test('package version and persona helper make durable memory visible', () => { + assert.equal(TURN_KIT_VERSION, packageJson.version); + const persona = defineTurnPersona({ + id: 'joke-bot', + intent: 'relay-orchestrator', + description: 'Keeps a conversation.', + harnessSettings: { reasoning: 'low', timeoutSeconds: 300 }, + onEvent: './agent.ts', + memory: { enabled: true, scopes: ['workspace'], ttlDays: 30 } + }); + assert.equal(persona.memory.ttlDays, 30); +}); + +test('conversation identity keeps transports and threads isolated', () => { + assert.equal(conversationKey('C123'), 'C123'); + assert.equal(conversationKey('C123', '171.2'), 'C123:171.2'); + assert.equal( + conversationTag('joke-bot', { transport: 'telegram', id: '8587:42' }), + 'turn:joke-bot:telegram:8587%3A42' + ); + assert.notEqual( + conversationTag('joke-bot', { transport: 'telegram', id: '8587' }), + conversationTag('joke-bot', { transport: 'slack', id: '8587' }) + ); + assert.throws( + () => conversationTag('Not Valid', { transport: 'telegram', id: '1' }), + /lowercase slug/ + ); +}); + +test('timestamped memory is always returned oldest first', () => { + const history = normalizeTurnHistory([ + memoryItem('third', '2026-07-24T12:03:00.000Z'), + memoryItem('first', '2026-07-24T12:01:00.000Z'), + memoryItem('second', '2026-07-24T12:02:00.000Z') + ]); + assert.deepEqual(history.map((entry) => entry.content), ['first', 'second', 'third']); +}); + +test('timestamp-less cloud recall defaults from newest-first to chronological', () => { + assert.deepEqual( + normalizeTurnHistory(['newest', 'middle', 'oldest']).map((entry) => entry.content), + ['oldest', 'middle', 'newest'] + ); + assert.deepEqual( + normalizeTurnHistory(['oldest', 'newest'], 'oldest-first').map((entry) => entry.content), + ['oldest', 'newest'] + ); +}); + +test('runner composes recall, deterministic context, ack, delivery, then memory', async () => { + const order: string[] = []; + const saved: Array<{ content: string; tags?: string[] }> = []; + const ctx = fakeCtx({ + recall: async () => { + order.push('recall'); + return [ + memoryItem('User: old\nAssistant: earlier', '2026-07-24T12:00:00.000Z') + ]; + }, + save: async (content, options) => { + order.push('save'); + saved.push({ content, tags: options?.tags }); + return { id: 'mem-2' }; + } + }); + const runner = createTurnRunner({ + namespace: 'life-agent', + memory: { + query: 'recent life-agent conversation', + limit: 6, + ttlSeconds: 3600, + userLabel: 'Khaliq', + assistantLabel: 'life-agent' + }, + context: [ + defineTurnContext({ + name: 'task-store', + collect: ({ history }) => { + order.push('context'); + assert.equal(history[0]?.content, 'User: old\nAssistant: earlier'); + return { title: 'Open tasks', content: '#7 Call Mom' }; + } + }) + ] + }); + + const result = await runner.run(ctx, { + conversation: { transport: 'telegram', id: conversationKey('8587', 42) }, + input: 'remind me what is open', + acknowledge: async (message) => { + order.push('ack'); + assert.equal(message, 'Checking…'); + }, + respond: async ({ history, context, acknowledge }) => { + order.push('respond'); + assert.equal(history.length, 1); + assert.deepEqual(context, [{ title: 'Open tasks', content: '#7 Call Mom' }]); + assert.equal(await acknowledge('Checking…'), true); + return 'You have one task: call Mom.'; + }, + deliver: async (reply) => { + order.push('deliver'); + assert.match(reply, /call Mom/); + return { ok: true, messageId: '99' }; + }, + confirmDelivery: (receipt) => receipt.ok + }); + + assert.deepEqual(order, ['recall', 'context', 'respond', 'ack', 'deliver', 'save']); + assert.equal(result.memorySaved, true); + assert.equal(result.acknowledgements, 1); + assert.equal(saved[0]?.content, 'Khaliq: remind me what is open\nlife-agent: You have one task: call Mom.'); + assert.deepEqual(saved[0]?.tags, ['turn:life-agent:telegram:8587%3A42']); +}); + +test('unconfirmed delivery fails before memory can record a reply', async () => { + let saves = 0; + const ctx = fakeCtx({ + save: async () => { + saves += 1; + return { id: 'should-not-exist' }; + } + }); + const runner = createTurnRunner({ namespace: 'receipt-safe' }); + await assert.rejects( + () => + runner.run(ctx, { + conversation: { transport: 'telegram', id: '1' }, + input: 'do it', + respond: () => 'Done', + deliver: async () => ({ ok: false }), + confirmDelivery: (receipt) => receipt.ok + }), + UnconfirmedTurnDeliveryError + ); + assert.equal(saves, 0); +}); + +test('action success text cannot be constructed before its provider receipt', async () => { + let successCalls = 0; + await assert.rejects( + () => + runConfirmedTurnAction({ + name: 'create-task', + perform: async () => ({ receipt: { status: 'queued' }, title: 'Call Mom' }), + confirm: (result) => result.receipt.status === 'succeeded', + confirmed: (result) => { + successCalls += 1; + return `Tracked: ${result.title}`; + } + }), + UnconfirmedTurnActionError + ); + assert.equal(successCalls, 0); + + const line = await runConfirmedTurnAction({ + name: 'create-task', + perform: async () => ({ receipt: { status: 'succeeded' }, title: 'Call Mom' }), + confirm: (result) => result.receipt.status === 'succeeded', + confirmed: (result) => { + successCalls += 1; + return `Tracked: ${result.title}`; + } + }); + assert.equal(line, 'Tracked: Call Mom'); + assert.equal(successCalls, 1); +}); + +test('acknowledgement failure is best-effort and final delivery still completes', async () => { + const logs: Array<{ level: string; message: string }> = []; + const ctx = fakeCtx({}, logs); + const result = await createTurnRunner({ + namespace: 'slow-agent', + memory: false + }).run(ctx, { + conversation: { transport: 'slack', id: 'C1:T1' }, + input: 'look this up', + acknowledge: async () => { + throw new Error('no receipt'); + }, + respond: async ({ acknowledge }) => { + assert.equal(await acknowledge('Looking…'), false); + return 'Here is the result.'; + }, + deliver: async () => ({ ts: '123.4' }) + }); + assert.equal(result.acknowledgements, 0); + assert.equal(result.memorySaved, false); + assert.equal( + logs.some((entry) => entry.message === 'turn-kit.acknowledgement-failed'), + true + ); +}); + +test('required context fails closed while optional context degrades', async () => { + let delivered = 0; + const ctx = fakeCtx(); + const required = createTurnRunner({ + namespace: 'grounded-agent', + memory: false, + context: [ + defineTurnContext({ + name: 'exact-state', + collect: async () => { + throw new Error('state unavailable'); + } + }) + ] + }); + await assert.rejects( + () => + required.run(ctx, { + conversation: { transport: 'relay', id: 'peer' }, + input: 'what happened?', + respond: () => 'should not run', + deliver: async () => { + delivered += 1; + } + }), + /state unavailable/ + ); + assert.equal(delivered, 0); + + const optional = createTurnRunner({ + namespace: 'grounded-agent', + memory: false, + context: [ + defineTurnContext({ + name: 'nice-to-have', + optional: true, + collect: async () => { + throw new Error('temporarily unavailable'); + } + }) + ] + }); + const result = await optional.run(ctx, { + conversation: { transport: 'relay', id: 'peer' }, + input: 'hello', + respond: ({ context }) => { + assert.deepEqual(context, []); + return { reply: 'Hello', remember: false }; + }, + deliver: async () => { + delivered += 1; + } + }); + assert.equal(result.reply, 'Hello'); + assert.equal(delivered, 1); +}); + +test('missing memory save receipt is visible without failing a delivered chat turn', async () => { + const logs: Array<{ level: string; message: string }> = []; + const ctx = fakeCtx({ save: async () => undefined }, logs); + const result = await createTurnRunner({ namespace: 'memory-aware' }).run(ctx, { + conversation: { transport: 'telegram', id: '1' }, + input: 'hello', + respond: () => 'Hi', + deliver: async () => ({ ok: true }), + confirmDelivery: (receipt) => receipt.ok + }); + assert.equal(result.memorySaved, false); + assert.equal( + logs.some((entry) => entry.message === 'turn-kit.memory-save-unconfirmed'), + true + ); +}); + +function memoryItem(content: string, createdAt: string): MemoryItem { + return { + id: `mem-${createdAt}`, + content, + tags: [], + scope: 'workspace', + createdAt + }; +} + +function fakeCtx( + memory: Partial = {}, + logs: Array<{ level: string; message: string }> = [] +): WorkforceCtx { + return { + memory: { + recall: memory.recall ?? (async () => []), + save: memory.save ?? (async () => ({ id: 'mem-1' })) + }, + log(level: string, message: string) { + logs.push({ level, message }); + } + } as unknown as WorkforceCtx; +} diff --git a/packages/turn-kit/src/types.ts b/packages/turn-kit/src/types.ts new file mode 100644 index 00000000..c93e0600 --- /dev/null +++ b/packages/turn-kit/src/types.ts @@ -0,0 +1,147 @@ +import type { + MemoryRecallOptions, + MemorySaveOptions, + WorkforceCtx +} from '@agentworkforce/runtime'; + +export interface TurnConversation { + /** Transport or surface name, such as `telegram`, `slack`, or `relay`. */ + transport: string; + /** + * Stable transport-local identity. Include the thread/topic when separate + * conversations on the same channel must not share history. + */ + id: string; +} + +export interface TurnHistoryEntry { + content: string; + createdAt?: string; +} + +export type TurnRecallOrder = 'newest-first' | 'oldest-first'; + +export interface TurnMemoryOptions { + /** Semantic query used by ctx.memory.recall. Tags enforce conversation isolation. */ + query?: string; + /** Number of prior turn records supplied to the responder. Defaults to 8. */ + limit?: number; + /** Defaults to `workspace`. */ + scope?: MemoryRecallOptions['scope']; + /** Expiry for newly saved turns. */ + ttlSeconds?: MemorySaveOptions['ttlSeconds']; + /** + * Ordering of timestamp-less recall results. Cloud memory returns + * newest-first; timestamped records are always sorted chronologically. + */ + recallOrder?: TurnRecallOrder; + /** Human-readable labels used by the default turn serializer. */ + userLabel?: string; + assistantLabel?: string; + /** Customize the record saved after confirmed delivery. */ + serialize?: (input: string, reply: string) => string; + /** + * Fail the turn when recall fails or a save returns no receipt. Leave false + * for retryable chat handlers to avoid redelivering a reply after a late + * memory failure. + */ + required?: boolean; +} + +export interface TurnContext { + /** Short stable heading for logs and prompts. */ + title: string; + /** Grounding text or instructions supplied to the responder. */ + content: string; +} + +export interface TurnContextProviderArgs { + ctx: WorkforceCtx; + conversation: TurnConversation; + input: string; + /** Oldest first. */ + history: readonly TurnHistoryEntry[]; +} + +/** + * Deterministic grounding extension point. Exact state, provider reads, and + * index searches belong here; the model should synthesize only after these + * providers have run. + */ +export interface TurnContextProvider { + readonly name: Name; + /** Optional providers log and disappear when unavailable. Required providers fail closed. */ + readonly optional?: boolean; + collect( + args: TurnContextProviderArgs + ): TurnContext | readonly TurnContext[] | null | undefined | Promise< + TurnContext | readonly TurnContext[] | null | undefined + >; +} + +export interface TurnResponse { + reply: string; + /** + * `false` skips persistence. A string replaces the default serialized + * user/assistant record. + */ + remember?: false | string; +} + +export interface TurnResponderArgs { + ctx: WorkforceCtx; + conversation: TurnConversation; + input: string; + /** Oldest first. */ + history: readonly TurnHistoryEntry[]; + context: readonly TurnContext[]; + /** + * Send an interim, read-only status line. Failures are logged and return + * false; they never abort the final response. + */ + acknowledge(message: string): Promise; +} + +export interface TurnRequest { + conversation: TurnConversation; + input: string; + respond(args: TurnResponderArgs): TurnResponse | string | Promise; + /** + * Deliver the final reply on the inbound transport. This function must throw + * on failure unless `confirmDelivery` is supplied. + */ + deliver(reply: string): Receipt | Promise; + /** Optional interim delivery surface. */ + acknowledge?: (message: string) => unknown | Promise; + /** + * Receipt predicate for adapters that resolve both success and failure. A + * false result throws before conversation memory is written. + */ + confirmDelivery?: (receipt: Receipt) => boolean; +} + +export interface TurnRunResult { + reply: string; + receipt: Receipt; + context: readonly TurnContext[]; + acknowledgements: number; + /** False when memory is disabled, skipped, or returned no save receipt. */ + memorySaved: boolean; +} + +export interface TurnRunnerOptions< + Providers extends readonly TurnContextProvider[] = readonly TurnContextProvider[] +> { + /** Stable lowercase slug used in tags and logs. */ + namespace: string; + /** Conversation memory is enabled by default. */ + memory?: false | TurnMemoryOptions; + context?: Providers; +} + +export interface TurnRunner { + run( + ctx: WorkforceCtx, + request: TurnRequest + ): Promise>; +} diff --git a/packages/turn-kit/tsconfig.json b/packages/turn-kit/tsconfig.json new file mode 100644 index 00000000..df59da57 --- /dev/null +++ b/packages/turn-kit/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src/**/*.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 72d2a39b..25c9a1b2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -234,6 +234,15 @@ importers: specifier: ^0.5.3 version: 0.5.3 + packages/turn-kit: + dependencies: + '@agentworkforce/persona-kit': + specifier: workspace:* + version: link:../persona-kit + '@agentworkforce/runtime': + specifier: workspace:* + version: link:../runtime + packages/workload-router: dependencies: '@agentworkforce/persona-kit': From 0674f8501f4e2c0960ae3ab64788e63ea28d2321 Mon Sep 17 00:00:00 2001 From: Ricky Schema Cascade Date: Fri, 24 Jul 2026 10:33:12 +0200 Subject: [PATCH 2/5] fix: keep release notes in package order --- .github/workflows/publish.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 7870d581..3762c872 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -713,14 +713,18 @@ jobs: // packages" step. Sorting release-notes entries by this order // ensures missing packages don't all collapse to indexOf=-1. const packageOrder = [ + 'events', 'persona-kit', 'runtime', + 'compose', 'delivery', 'workload-router', 'deploy', + 'review-kit', 'turn-kit', 'mcp-workforce', 'daytona-runner', + 'local-surface', 'cli', 'agentworkforce', ]; From 4228e27ee6f78cbeccd74f52ae7c9c4f06e52877 Mon Sep 17 00:00:00 2001 From: Ricky Schema Cascade Date: Fri, 24 Jul 2026 13:52:41 +0200 Subject: [PATCH 3/5] feat: bridge turn kit to agent assistant context --- packages/turn-kit/CHANGELOG.md | 6 + packages/turn-kit/README.md | 59 +- packages/turn-kit/package.json | 15 + packages/turn-kit/src/assistant.test.ts | 88 +++ packages/turn-kit/src/assistant.ts | 133 +++++ packages/turn-kit/src/context.ts | 20 +- packages/turn-kit/src/turn-kit.test.ts | 16 +- packages/turn-kit/src/types.ts | 12 +- pnpm-lock.yaml | 730 ++++++++++++++++++++++++ 9 files changed, 1069 insertions(+), 10 deletions(-) create mode 100644 packages/turn-kit/src/assistant.test.ts create mode 100644 packages/turn-kit/src/assistant.ts diff --git a/packages/turn-kit/CHANGELOG.md b/packages/turn-kit/CHANGELOG.md index 3d626a85..914a25e8 100644 --- a/packages/turn-kit/CHANGELOG.md +++ b/packages/turn-kit/CHANGELOG.md @@ -16,3 +16,9 @@ file. the bundled kit version in runtime logs. - Add a provider-agnostic confirmed-action helper that constructs success output only after the caller's receipt predicate passes. +- Add the optional `@agentworkforce/turn-kit/assistant` bridge so Workforce + history and deterministic context blocks can use + `@agent-assistant/turn-context` for canonical identity/context assembly and + harness projection. +- Align deterministic context blocks with the Agent Assistant prepared-context + shape (`id`, `label`, `content`, source/category metadata). diff --git a/packages/turn-kit/README.md b/packages/turn-kit/README.md index f841c74c..3ac8af99 100644 --- a/packages/turn-kit/README.md +++ b/packages/turn-kit/README.md @@ -26,8 +26,11 @@ const turns = createTurnRunner({ defineTurnContext({ name: 'open-work', collect: async ({ ctx }) => ({ - title: 'Open work', - content: await ctx.files.read('/my-agent/open-work.json') + id: 'open-work', + label: 'Open work', + content: await ctx.files.read('/my-agent/open-work.json'), + source: 'relayfile', + category: 'workspace' }) }) ] @@ -49,6 +52,58 @@ await turns.run(ctx, { }); ``` +## Agent Assistant alignment + +Workforce owns the event and delivery lifecycle in this package. Assistant +identity, turn-scoped prompt/context assembly, guardrails, provenance, and +harness projection remain owned by `@agent-assistant/turn-context`. + +Agents that need that richer assistant layer can opt into the bridge without +adding the Agent Assistant runtime graph to every turn-kit consumer: + +```sh +pnpm add @agentworkforce/turn-kit @agent-assistant/turn-context +``` + +```ts +import { + assembleAssistantTurnContext +} from '@agentworkforce/turn-kit/assistant'; + +const assembly = await assembleAssistantTurnContext({ + assistantId: 'my-agent', + turnId: message.id, + conversation, + identity: { + assistantName: 'My Agent', + baseInstructions: { + systemPrompt: ctx.persona.systemPrompt ?? 'You are a helpful assistant.' + } + }, + history, + context +}); + +// Directly compatible with @agent-assistant/harness. +console.log(assembly.harnessProjection); +``` + +The split is deliberate: + +- `turn-kit` adapts `WorkforceCtx`, transport delivery, acknowledgements, + provider receipts, and post-delivery `ctx.memory` persistence. +- `@agent-assistant/turn-context` assembles assistant identity and effective + context for a bounded turn. +- `@agent-assistant/sessions` is appropriate when a product needs affinity + across surfaces. Pass its resolved session id as `sessionId`; a transport + chat id remains sufficient for simple Telegram or Slack continuity. +- `@agent-assistant/continuation` applies when a harness returns a resumable + clarification, approval, or deferred outcome. It is not required for every + inbound chat message. +- `@agent-assistant/memory` is not substituted for `ctx.memory`: Workforce's + cloud-managed surface currently exposes semantic save/recall, while the + Assistant SDK store requires structured CRUD/list adapters. + Provider mutations can use `runConfirmedTurnAction()` so user-visible success text is not even constructed until the receipt predicate passes: diff --git a/packages/turn-kit/package.json b/packages/turn-kit/package.json index 7e8cdc38..048d6058 100644 --- a/packages/turn-kit/package.json +++ b/packages/turn-kit/package.json @@ -10,6 +10,10 @@ "types": "./dist/index.d.ts", "default": "./dist/index.js" }, + "./assistant": { + "types": "./dist/assistant.d.ts", + "default": "./dist/assistant.js" + }, "./package.json": "./package.json" }, "files": [ @@ -36,5 +40,16 @@ "dependencies": { "@agentworkforce/persona-kit": "workspace:*", "@agentworkforce/runtime": "workspace:*" + }, + "peerDependencies": { + "@agent-assistant/turn-context": "^0.4.35" + }, + "peerDependenciesMeta": { + "@agent-assistant/turn-context": { + "optional": true + } + }, + "devDependencies": { + "@agent-assistant/turn-context": "^0.4.35" } } diff --git a/packages/turn-kit/src/assistant.test.ts b/packages/turn-kit/src/assistant.test.ts new file mode 100644 index 00000000..4469da80 --- /dev/null +++ b/packages/turn-kit/src/assistant.test.ts @@ -0,0 +1,88 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { assembleAssistantTurnContext } from './assistant.js'; + +test('assistant bridge reuses canonical turn-context assembly and harness projection', async () => { + const assembly = await assembleAssistantTurnContext({ + assistantId: 'life-agent', + turnId: 'telegram-update-42', + conversation: { transport: 'telegram', id: '8587:7' }, + identity: { + assistantName: 'Life Agent', + baseInstructions: { + systemPrompt: 'You are a personal life assistant.' + } + }, + shaping: { + mode: 'personal-assistance', + responseStyle: { preferMarkdown: true } + }, + history: [ + { + content: 'User: Add the flight task\nAssistant: Added it.', + createdAt: '2026-07-24T10:00:00.000Z' + } + ], + context: [ + { + id: 'open-tasks', + label: 'Open tasks', + content: '#5 Book the hotel', + source: 'github', + category: 'workspace' + } + ], + guardrails: { + overlays: [ + { + id: 'confirmed-writes', + source: 'workforce', + rule: 'Only claim a write succeeded after its provider receipt.', + kind: 'truthfulness_constraint', + priority: 'high' + } + ] + } + }); + + assert.equal(assembly.sessionId, 'turn:life-agent:telegram:8587%3A7'); + assert.deepEqual(assembly.provenance.usedMemoryIds, [ + 'telegram-update-42:history:1' + ]); + assert.deepEqual(assembly.provenance.usedGuardrailIds, ['confirmed-writes']); + assert.deepEqual( + assembly.context.blocks.map((block) => block.id), + ['memory-telegram-update-42:history:1', 'open-tasks'] + ); + assert.equal( + assembly.harnessProjection.context.blocks.at(-1)?.content, + '#5 Book the hotel' + ); + assert.match( + assembly.harnessProjection.instructions.developerPrompt ?? '', + /provider receipt/ + ); +}); + +test('assistant bridge rejects ids that collide with assembled memory blocks', async () => { + await assert.rejects( + () => + assembleAssistantTurnContext({ + assistantId: 'life-agent', + turnId: 'turn-1', + conversation: { transport: 'telegram', id: '8587' }, + identity: { + baseInstructions: { systemPrompt: 'Be helpful.' } + }, + history: [{ content: 'Earlier turn' }], + context: [ + { + id: 'memory-turn-1:history:1', + label: 'Collision', + content: 'This id is already used.' + } + ] + }), + /duplicate assembled turn context id/ + ); +}); diff --git a/packages/turn-kit/src/assistant.ts b/packages/turn-kit/src/assistant.ts new file mode 100644 index 00000000..a6b126e5 --- /dev/null +++ b/packages/turn-kit/src/assistant.ts @@ -0,0 +1,133 @@ +/** + * Optional bridge from Workforce's turn lifecycle to the canonical + * Agent Assistant turn-context assembly contract. + * + * Import this module through `@agentworkforce/turn-kit/assistant` and install + * the optional `@agent-assistant/turn-context` peer when assistant identity, + * guardrails, provenance, or harness projection are needed. + */ +import { + createTurnContextAssembler, + projectToHarness, + type TurnContextAssembler, + type TurnContextAssembly, + type TurnGuardrailInput, + type TurnIdentityInput, + type TurnSessionInput, + type TurnShapingInput +} from '@agent-assistant/turn-context'; +import { conversationTag } from './memory.js'; +import type { + TurnContext, + TurnConversation, + TurnHistoryEntry +} from './types.js'; + +export interface AssistantTurnContextInput { + /** Stable lowercase Workforce persona/agent id. */ + assistantId: string; + /** Unique id for this bounded turn, normally the inbound event id. */ + turnId: string; + conversation: TurnConversation; + /** Product-owned identity floor; never inferred from transient context. */ + identity: TurnIdentityInput; + /** Oldest-first conversation records recalled through Workforce ctx.memory. */ + history?: readonly TurnHistoryEntry[]; + /** Exact deterministic blocks collected by Workforce context providers. */ + context?: readonly TurnContext[]; + userId?: string; + /** Override when @agent-assistant/sessions resolved a broader assistant session. */ + sessionId?: string; + threadId?: string; + session?: TurnSessionInput; + shaping?: TurnShapingInput; + guardrails?: TurnGuardrailInput; + metadata?: Record; +} + +export interface AssembleAssistantTurnContextOptions { + /** + * Inject a configured assembler, for example one with an Agent Assistant + * memory retriever. When `history` is supplied it remains authoritative. + */ + assembler?: TurnContextAssembler; +} + +/** + * Assemble Workforce history and exact provider blocks with Agent Assistant's + * identity, guardrail, provenance, and harness projection rules. + */ +export async function assembleAssistantTurnContext( + input: AssistantTurnContextInput, + options: AssembleAssistantTurnContextOptions = {} +): Promise { + const assembler = options.assembler ?? createTurnContextAssembler(); + const history = input.history?.map((entry, index) => ({ + id: `${input.turnId}:history:${index + 1}`, + text: entry.content, + scope: 'session' as const, + source: 'workforce-memory', + metadata: { + ...(entry.createdAt !== undefined ? { createdAt: entry.createdAt } : {}), + chronologicalIndex: index + } + })); + + const assembly = await assembler.assemble({ + assistantId: input.assistantId, + turnId: input.turnId, + sessionId: + input.sessionId ?? + conversationTag(input.assistantId, input.conversation), + ...(input.userId !== undefined ? { userId: input.userId } : {}), + threadId: input.threadId ?? input.conversation.id, + identity: input.identity, + ...(input.shaping !== undefined ? { shaping: input.shaping } : {}), + ...(input.session !== undefined ? { session: input.session } : {}), + ...(history !== undefined ? { memory: { candidates: history } } : {}), + ...(input.guardrails !== undefined ? { guardrails: input.guardrails } : {}), + metadata: { + ...(input.metadata ?? {}), + workforceTransport: input.conversation.transport, + workforceConversationId: input.conversation.id + } + }); + + if (!input.context?.length) return assembly; + + const context = { + ...assembly.context, + blocks: [...assembly.context.blocks, ...input.context] + }; + assertUniqueContextIds(context.blocks); + + return { + ...assembly, + context, + harnessProjection: projectToHarness( + assembly.instructions, + context, + input.shaping?.responseStyle + ) + }; +} + +function assertUniqueContextIds( + blocks: readonly { id: string }[] +): void { + const ids = new Set(); + for (const block of blocks) { + if (ids.has(block.id)) { + throw new TypeError(`duplicate assembled turn context id: ${block.id}`); + } + ids.add(block.id); + } +} + +export type { + TurnContextAssembly, + TurnGuardrailInput, + TurnIdentityInput, + TurnSessionInput, + TurnShapingInput +} from '@agent-assistant/turn-context'; diff --git a/packages/turn-kit/src/context.ts b/packages/turn-kit/src/context.ts index fa9c4723..3fcf81d5 100644 --- a/packages/turn-kit/src/context.ts +++ b/packages/turn-kit/src/context.ts @@ -33,6 +33,13 @@ export async function collectTurnContext( }) ); const context = collected.flat(); + const seenIds = new Set(); + for (const block of context) { + if (seenIds.has(block.id)) { + throw new TypeError(`duplicate turn context id: ${block.id}`); + } + seenIds.add(block.id); + } args.ctx.log?.('info', 'turn-kit.context-collected', { providers: providers.length, sections: context.length @@ -50,11 +57,18 @@ function normalizeContext( ? value : [value]; return entries.map((entry) => { - const title = entry.title.trim(); + const id = entry.id.trim(); + const label = entry.label.trim(); const content = entry.content.trim(); - if (!title || !content) { + if (!id || !label || !content) { throw new TypeError(`turn context provider ${provider} returned an empty section`); } - return { title, content }; + return { + ...entry, + id, + label, + content, + ...(entry.source !== undefined ? { source: entry.source.trim() } : {}) + }; }); } diff --git a/packages/turn-kit/src/turn-kit.test.ts b/packages/turn-kit/src/turn-kit.test.ts index 9b70255c..20ceed6e 100644 --- a/packages/turn-kit/src/turn-kit.test.ts +++ b/packages/turn-kit/src/turn-kit.test.ts @@ -99,7 +99,13 @@ test('runner composes recall, deterministic context, ack, delivery, then memory' collect: ({ history }) => { order.push('context'); assert.equal(history[0]?.content, 'User: old\nAssistant: earlier'); - return { title: 'Open tasks', content: '#7 Call Mom' }; + return { + id: 'open-tasks', + label: 'Open tasks', + content: '#7 Call Mom', + source: 'task-store', + category: 'workspace' + }; } }) ] @@ -115,7 +121,13 @@ test('runner composes recall, deterministic context, ack, delivery, then memory' respond: async ({ history, context, acknowledge }) => { order.push('respond'); assert.equal(history.length, 1); - assert.deepEqual(context, [{ title: 'Open tasks', content: '#7 Call Mom' }]); + assert.deepEqual(context, [{ + id: 'open-tasks', + label: 'Open tasks', + content: '#7 Call Mom', + source: 'task-store', + category: 'workspace' + }]); assert.equal(await acknowledge('Checking…'), true); return 'You have one task: call Mom.'; }, diff --git a/packages/turn-kit/src/types.ts b/packages/turn-kit/src/types.ts index c93e0600..4c0a3f21 100644 --- a/packages/turn-kit/src/types.ts +++ b/packages/turn-kit/src/types.ts @@ -49,10 +49,16 @@ export interface TurnMemoryOptions { } export interface TurnContext { - /** Short stable heading for logs and prompts. */ - title: string; - /** Grounding text or instructions supplied to the responder. */ + /** Stable block identity. Keep this unique within one assembled turn. */ + id: string; + /** Human-readable heading for logs and prompts. */ + label: string; + /** Grounding text or instructions supplied to the responder or harness. */ content: string; + importance?: 'low' | 'medium' | 'high'; + source?: string; + category?: 'memory' | 'session' | 'enrichment' | 'workspace' | 'guardrail' | 'other'; + metadata?: Record; } export interface TurnContextProviderArgs { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 25c9a1b2..ea118d4b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -242,6 +242,10 @@ importers: '@agentworkforce/runtime': specifier: workspace:* version: link:../runtime + devDependencies: + '@agent-assistant/turn-context': + specifier: ^0.4.35 + version: 0.4.35 packages/workload-router: dependencies: @@ -254,49 +258,115 @@ packages: '@agent-assistant/connectivity@0.2.24': resolution: {integrity: sha512-Nkrv8xJnQrX+6nzGVqnBGdcit6yqsF0mA4rk2kfX2Kduv14lVyVM9wjpLWLLF165v7LifCg/VlatYBPTEbLxRw==} + '@agent-assistant/coordination@0.2.24': + resolution: {integrity: sha512-InsJwU05TtGxS9iHawFl8EZtzHfesauMaYq6belYrIWF9n/uG7VD9le4jiYnuh+o5/RPQq+Z/ZuK1QKqzad+UQ==} + '@agent-assistant/coordination@0.4.32': resolution: {integrity: sha512-qepgjfDSIbARR/do4oHhx0GytjIsNzC/ETZKRtEeF1/fWzHp9zLfUBAz9/KjX3EUWNxcQ3LMVNeR/Ai3qpUtZQ==} + '@agent-assistant/core@0.2.24': + resolution: {integrity: sha512-lHEkhObn25O3wrDXLR+puQNtulumrwbXuUH2CvdlqfqHnDXtukmJcxnuRw6M9F3antGJOeS4YNcFpB35JlGtlA==} + peerDependencies: + '@agent-assistant/traits': '>=0.1.0' + + '@agent-assistant/harness@0.4.35': + resolution: {integrity: sha512-qbVPRc5LWhz577L/b2TamZBBh8Sej3O/47EaSBp5F0t+XK3NaUj+qKIChu67c11aElm+rwSLiIT7CydPDiUAsQ==} + + '@agent-assistant/memory@0.2.24': + resolution: {integrity: sha512-Cjhwq5MsBSFPBvP1yebcY4pZf/+qN2ZQbvCgl78+2gi07Ul8AyYyuCfjc72EMnEMrklrSCS0s/Uy3EHFbZJPpw==} + + '@agent-assistant/memory@0.4.35': + resolution: {integrity: sha512-2mTQIUtzIIaTiahN86HnnQ+7I8H1b7dRbK9sm19xQ/5br/ybDKCCPv3tcxKS6PcFOhPNXQTzJLhzj8tNCV7pqQ==} + '@agent-assistant/proactive@0.4.32': resolution: {integrity: sha512-Bl2iXw7Iyyyr47+1c7E8Y9jpnezA7MMA9SnfxDEk1p1WKR77+XIRrKwgHiytRok6qJbb4WjfXEfsO3ExhFXEnQ==} '@agent-assistant/surfaces@0.4.32': resolution: {integrity: sha512-VI1UpwDD/RznfngRKatqL3zkZ6Bo9MszIGwZ5/H68r+6yghsFeTSq42eyoQM90DuQniX6Z/QfPmA890U1ZA2MQ==} + '@agent-assistant/traits@0.2.24': + resolution: {integrity: sha512-oopf1b1qO3PS9Yp+XJZFH6r7mSdKMfsdqQBz+pSFI9pcJacL0j1Jo8ECmmzwOP+3kUcudmK9SerLMQIs64Qh0Q==} + + '@agent-assistant/turn-context@0.3.21': + resolution: {integrity: sha512-QBM/pgl2Z9L95nlnI8P5U3w4ivDG1IhV9UNle+cz0edEDcfITmzTuxqTwSkcjt3ODHCBssHI5XF6dN6f0g2ECQ==} + + '@agent-assistant/turn-context@0.4.35': + resolution: {integrity: sha512-oNgzh1WR/99tig0sfreJJ6HWvdQslBvzClUv3aZeN6+tUgLXT2GS49Ekv7nyF+tQoSxQ3qkKuzTB8z3kHRQBHQ==} + + '@agent-assistant/vfs@0.2.24': + resolution: {integrity: sha512-yRT0YMMwskDg5aEvwn6iUDQZxgYqD8AtbIL3dnb+cdHkyd5hoKDi9rQiHq7Mi+yBg/s9ja4cGks9kI1pNLemQA==} + + '@agent-assistant/vfs@0.4.35': + resolution: {integrity: sha512-0pMO157u9+g+LO9DOqtlJbQ566n8/bXbY2ktZoz24ldvni0U6qyDLKZEcJDtZXWaIE6LlwHtXxdHNqhCm0R5/g==} + '@agent-relay/broker-darwin-arm64@10.2.0': resolution: {integrity: sha512-JuIU0FbL50d6iXgp3NimdzWwXMc++R3TK5D4a0FlnMXMFUYbMLTwZdbtvOf4SdfLowTLhh+P+2HHG4x3dCuDVw==} cpu: [arm64] os: [darwin] + '@agent-relay/broker-darwin-arm64@6.3.6': + resolution: {integrity: sha512-5JlfwPSRRPK4DRIoePva1EAqd4sqPcdJvMkYHNbQP0B7fV3SpWJo1mi7debOv6yK0s8ZxER47cyLxHsUM0AYgQ==} + cpu: [arm64] + os: [darwin] + '@agent-relay/broker-darwin-x64@10.2.0': resolution: {integrity: sha512-KtPSTenzaYB0D1AG0ZBVv90N/Owm2Qu4PE2sfRBB0FPGZUj1VzeT5ecTE0j1Pt1XJji5jX9Hg3R87pgGq9Ev+A==} cpu: [x64] os: [darwin] + '@agent-relay/broker-darwin-x64@6.3.6': + resolution: {integrity: sha512-BxaaTqojtRE5fXuoP4noZz5LVTWL+Wj3PiuBaWMgxoLnkg2Z1uvj97Q+be1jsaQSUgsF8x8JgjzKsfOq1Kc12A==} + cpu: [x64] + os: [darwin] + '@agent-relay/broker-linux-arm64@10.2.0': resolution: {integrity: sha512-cY0/rrmI1h5thGbiFbi4dneZ/2lOiixH4ldjGEUtkkYt1KM+z51dpgXEhOgADibzkQUZayaHlue7+JzlMgXJFA==} cpu: [arm64] os: [linux] + '@agent-relay/broker-linux-arm64@6.3.6': + resolution: {integrity: sha512-ZYvz7pa+sDzTgoiIk0pH6AryV6XuB3TODKNqqcKFueyiTAgZ3Rk8AJaN1Xr3X75T7Wdr9629ZOXH26Rqz5Q5KQ==} + cpu: [arm64] + os: [linux] + '@agent-relay/broker-linux-x64@10.2.0': resolution: {integrity: sha512-rj9PsbeDv1NoJVkBpSCEkagT8wzfOO26Wcwg1OxwREvo2pLCtns2b3okxqxxTLStnujcyJ+1bNrjmLyV4nTNUQ==} cpu: [x64] os: [linux] + '@agent-relay/broker-linux-x64@6.3.6': + resolution: {integrity: sha512-rhX95c5uoZDW+yt+1PgPrrfEei8UAnoSxnTlMHAOCx6jWq+1q7VNkH2ZMcp7qcXkQRq5EjSvAk2Y7voXeqlqKg==} + cpu: [x64] + os: [linux] + '@agent-relay/broker-win32-x64@10.2.0': resolution: {integrity: sha512-+x1kN6stM4ZC8K7j45swXYLgYh5TD/hX1m/Xr5nBoFyBWp9dPjZHss2uyFtM+UpL46l3nO0mzYAcM1qdulrjZA==} cpu: [x64] os: [win32] + '@agent-relay/broker-win32-x64@6.3.6': + resolution: {integrity: sha512-Tn771YPvB6z5/wmg1S24Q3sTVoWrjPjwJhnBpeePPKTgarHurOzImm/Ak121scQSeKPI2+5SrQfZCBbTR5uNoA==} + cpu: [x64] + os: [win32] + '@agent-relay/cloud@10.2.0': resolution: {integrity: sha512-ZHKkXK65p+3PTjdnGpoZQHC0pqgyDagwNr0bifHdycCYXt0e+YMNZWVMzdyZUpmRgqg+EYCQ9hh8287LrdJgHA==} + '@agent-relay/cloud@6.3.6': + resolution: {integrity: sha512-XcZH1ZUIyiidqE1ZBmJFKIiDPLYkWStJPWtHkLrHmxIgPdQqSACXI+Jh8rwwm/gE7QNG7XJc4HKUOhENVyIX8g==} + '@agent-relay/cloud@8.7.0': resolution: {integrity: sha512-7cXD1ziMOuAOy3IbVk0P6qDVipTYxbVFBWmxn9aME7hTHyy4AzfjLOj51AL7JLpDKj4I5OiXJjw12B29Wg/Krw==} '@agent-relay/config@10.2.0': resolution: {integrity: sha512-/Pz8TvyOz49gWJ8hov8ymsOkRmApoYtUoQz3VFa11NU2y4lyHpYPG9PsM3NlPZ46DGzT7c72zvSDjI+pl5l+Lg==} + '@agent-relay/config@4.0.40': + resolution: {integrity: sha512-SEXTOTlxkC2kss17YzvAR9bmwMIBclurjI0O2k5xbxxqK/dH3iMM4sJpXXqat1iug95Lrp2Vp/hQJt6xOGeI9g==} + + '@agent-relay/config@6.3.6': + resolution: {integrity: sha512-kxqH6z2w1ESu7MXQa7LqA5W5pUOU46Vw51NrtvfmKfxgQnrk+bGtHQJjhPMu/hju/msXWwhBdHQ0Qq57SdMWXw==} + '@agent-relay/config@8.7.0': resolution: {integrity: sha512-hCH0jPSbeBYoZs/Jx8NGDqbPe93xEXlF3q0E6HCO8+niLAuYZU3+hDWsS2dvDCg6S7BH5cTQbVUyMxV8T48U8Q==} @@ -323,15 +393,106 @@ packages: '@agent-relay/fleet@10.2.0': resolution: {integrity: sha512-Injya3fvQYOn9GoYrycgaHOOoh6TkyNkwnoYiHpPx56JhzsEQxMrQwjWzpvA+mpfPS7elO4W7m0Va2aF8PHBvQ==} + '@agent-relay/github-primitive@6.3.6': + resolution: {integrity: sha512-yMGGN5ahqCr+REy1pFRvD0QJm7s+4Qh2UwZ5e8Jg7VpSEzsHtu6zXRwdB+Jwrr3lHmDGtST5ief8cWy23l0wJg==} + '@agent-relay/harness-driver@10.2.0': resolution: {integrity: sha512-EJRbOwBF8IHPrHjyJVEkQ1XJb3WwnZ4GeUe7iy22Sdh+n0CX5EfjxOs4K5hSdKmwH29VbwjXE+lzPXbj7qMixQ==} '@agent-relay/harnesses@10.2.0': resolution: {integrity: sha512-P7Ud1hfVUjFeTbbXeYeYK7GcQy7bYixm2xgWbi1OVGEd/RqpncNl7obpmc+Tf6lnXPSEUXx7KUTBp2We5FhzlQ==} + '@agent-relay/hooks@4.0.40': + resolution: {integrity: sha512-WVbmXtJV3dHsKXs7zVOMpjeuEGBBWt0DCkqLuANKQMAaR2FkrhUbK0XZWL3lz2IHDlByM3tu9y4KgzbSoKu5hA==} + + '@agent-relay/hooks@6.3.6': + resolution: {integrity: sha512-RkQdbckP6bVRXt+M8e3Dg/6ElCvSios3SR/8Vb2KCAoalPQR2QprJJZ3D68FbDtPC9Ca2KrguZ3hWKa791hDZg==} + + '@agent-relay/memory@4.0.40': + resolution: {integrity: sha512-W/pUIMq4FrxmVqn73mUoEz4mEyBrmrqrkW2uNWf/cxRQZoMki4D9dNCO6QiTVNHUNxFdXhMuS8fxLP8Ht3NEdg==} + + '@agent-relay/memory@6.3.6': + resolution: {integrity: sha512-jetbd2FPOuflN4Ykjw/1QTCNxXTc9s8gzq0iSOmQJF4lpGSRXHfP8sdQCLPNnh0oun17Q7NQmtoRGau3xkr55g==} + '@agent-relay/sdk@10.2.0': resolution: {integrity: sha512-NGN+UbN5PNf4Yh9y3PkUXOHlziPX9zccqbsh8URtiviJ3WZN6+6/JH25xadj7RlZQP7izwEdl89ql2PTapp1aQ==} + '@agent-relay/sdk@4.0.40': + resolution: {integrity: sha512-/65zrEALDUOPU96SBMBl462r6J5w/vQyshR0OV9KnLfzp5eRBhgv9p3beeFDgq6WuLto/A28U5zgnTyST3/n4g==} + peerDependencies: + '@agent-relay/credential-proxy': 4.0.40 + '@anthropic-ai/claude-agent-sdk': '>=0.1.0' + '@google/adk': '>=0.5.0' + '@langchain/langgraph': '>=1.2.0' + '@mariozechner/pi-coding-agent': '>=0.50.0' + '@openai/agents': '>=0.7.0' + ai: '>=5.0.0' + crewai: '>=1.0.0' + peerDependenciesMeta: + '@agent-relay/credential-proxy': + optional: true + '@anthropic-ai/claude-agent-sdk': + optional: true + '@google/adk': + optional: true + '@langchain/langgraph': + optional: true + '@mariozechner/pi-coding-agent': + optional: true + '@openai/agents': + optional: true + ai: + optional: true + crewai: + optional: true + + '@agent-relay/sdk@6.3.6': + resolution: {integrity: sha512-0+IB22GS8qkncS0CMDI5sz9V1k3jChjq2F+Ns1fF69a2rWMUHhmcVwR0mNNypnATSjDWWgIXhJwnsjuSm6ulpA==} + peerDependencies: + '@agent-relay/credential-proxy': 6.3.6 + '@anthropic-ai/claude-agent-sdk': '>=0.1.0' + '@google/adk': '>=0.5.0' + '@langchain/langgraph': '>=1.2.0' + '@mariozechner/pi-coding-agent': '>=0.50.0' + '@openai/agents': '>=0.7.0' + ai: '>=5.0.0' + crewai: '>=1.0.0' + peerDependenciesMeta: + '@agent-relay/credential-proxy': + optional: true + '@anthropic-ai/claude-agent-sdk': + optional: true + '@google/adk': + optional: true + '@langchain/langgraph': + optional: true + '@mariozechner/pi-coding-agent': + optional: true + '@openai/agents': + optional: true + ai: + optional: true + crewai: + optional: true + + '@agent-relay/slack-primitive@6.3.6': + resolution: {integrity: sha512-MAbVJmqCZmGm9D/T1OLNPjsm+APeH8bYE5thn09X9bJugHWhzTCXcJZFx/McsBq/IWlyQliyt6VHw5JbKfkZFA==} + + '@agent-relay/trajectory@4.0.40': + resolution: {integrity: sha512-+h0aRuT1Gmp6iTXACN+qcdh2ntIbZq6Bk55vTa7GGtyVUldLS5O2XSKDHUWn/gSiThUAlySApr4ZsG9lX1Jbkg==} + + '@agent-relay/trajectory@6.3.6': + resolution: {integrity: sha512-37h1o4bhzOl3lQHq0meaC0kXS+gw2m177WICu7a6ttGzGerI31jCUrPlEMWq3P1ieBe0nTbxMjgZw379/6u1fw==} + + '@agent-relay/workflow-types@6.3.6': + resolution: {integrity: sha512-o3K8yDB03KspX7X39NOr2fvTVb1XSob4MOExUzsnDENiMDLLQKlYz1gjYDejabqGWCbZi4NBbJmVU0kZAHi28w==} + + '@agentworkforce/harness-kit@0.11.0': + resolution: {integrity: sha512-CtW9P0pVm0j5R+kl7OaWMkPz7akYZqJNLmQ8k1m5Ony7NIfxJKuGiTBH9kcg+6vQ7fUtnfkoa34wt3y/pEh2QQ==} + + '@agentworkforce/workload-router@0.11.0': + resolution: {integrity: sha512-6Fn4oDsYeNRPe+k7hVfS3Ae3yIocNjuvscVvRswn74CzxSC1X9+1wDhQ5eCvE+S1m1ixAjYGFC9/MNwuhFwjHw==} + '@aws-crypto/crc32@5.2.0': resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} engines: {node: '>=16.0.0'} @@ -1250,9 +1411,15 @@ packages: '@relaycast/a2a@6.2.0': resolution: {integrity: sha512-gFz6yEHYFjxQ+zCcd8ELc7d4LXL2TI8bTOnJ0tkaYjt4krKn68LtOllc8kjxlUI05v2YXCARbZ022T8Y4yDc2g==} + '@relaycast/sdk@1.2.0': + resolution: {integrity: sha512-/tBN0Up1X+MMQzyyUq9jNSkoTuPtRWcfno3t5iO8PBCJkE9+b89RY+6SxcmII9+8EjlEgMb3xqYey414wDuwTQ==} + '@relaycast/sdk@6.0.5': resolution: {integrity: sha512-LNixPCBMsqD7yhuNvt8FzcEagdmnWnXvY62HZOlZbOrxRPDP1Wxnc/EDJVjRMP2TuRmLm7aHlkTIRZKG2bxdbA==} + '@relaycast/types@1.2.0': + resolution: {integrity: sha512-ZgnK3VN6RkE2/P+eDRmcr6f4N66yTELT3PHk4ZjIKlmZBL0vgwCZCKC4ZxJrEkcaOPWP4bx3LpajSIKWke6kYA==} + '@relaycast/types@6.0.5': resolution: {integrity: sha512-wTxtHHCZF13wa28BWcTuG4sf2fcrFGgI+MSywvdR6MjWGVJdriJMZekT9iVy9We1CpXjzKUmHIhhxjH/PQoPHw==} @@ -1316,6 +1483,21 @@ packages: resolution: {integrity: sha512-Y/tMuRZG34wEfpTxDfXFp5o2X3ibb5ojGWupGJ9ZxkThCx7rOGydnszJPzEbgDK3eF6nJ6UuE7bCTpIEutYnPw==} engines: {node: '>=22'} + '@sinclair/typebox@0.34.52': + resolution: {integrity: sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==} + + '@slack/logger@4.0.1': + resolution: {integrity: sha512-6cmdPrV/RYfd2U0mDGiMK8S7OJqpCTm7enMLRR3edccsPX8j7zXTLnaEF4fhxxJJTAIOil6+qZrnUPTuaLvwrQ==} + engines: {node: '>= 18', npm: '>= 8.6.0'} + + '@slack/types@2.22.0': + resolution: {integrity: sha512-sZ9lIgJhPX2qft/tKWiklFlc0o1FWeI7QtciZJfW1+ErH1eGGHvOZ8e73sleTCFEFJp1q/R0WeS8Oa7AsiDprg==} + engines: {node: '>= 12.13.0', npm: '>= 6.12.0'} + + '@slack/web-api@7.19.0': + resolution: {integrity: sha512-ItjyjEZml+LDH8CjcCLRLJHh7VZtevPKExrRN3l5KWyBliyDnGAeoO4Y+K+fFBmRpKLYVPgqWMX4THldv2HVtA==} + engines: {node: '>= 18', npm: '>= 8.6.0'} + '@smithy/config-resolver@4.5.1': resolution: {integrity: sha512-abXk3LhODsvRHsk0ZS9ztrg/fZatTa9Z/z4pgx65YSLR+rY6kvUG/1IgcDKEUciR8MfdnkT5oPeHJTy/HhzDIQ==} engines: {node: '>=18.0.0'} @@ -1491,6 +1673,9 @@ packages: '@types/node@22.19.15': resolution: {integrity: sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==} + '@types/retry@0.12.0': + resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==} + '@types/semver@7.7.1': resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} @@ -1516,6 +1701,11 @@ packages: engines: {node: '>=20.0.0'} hasBin: true + agent-trajectories@0.5.9: + resolution: {integrity: sha512-t6JhJ5Z+zI+Q/t/egSaAGd1jGewHNTCKiIzoOak7/08sLjxEgFlXCPyvCgfj0HCBkYTpSZddASXFQr8WWliSww==} + engines: {node: '>=20.0.0'} + hasBin: true + ajv-formats@3.0.1: resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} peerDependencies: @@ -1527,6 +1717,10 @@ packages: ajv@8.20.0: resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + ansi-escapes@7.3.0: + resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} + engines: {node: '>=18'} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -1539,6 +1733,10 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + asn1@0.2.6: resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==} @@ -1599,6 +1797,10 @@ packages: resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} engines: {node: '>= 0.4'} + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + chalk@5.6.2: resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} @@ -1625,6 +1827,10 @@ packages: resolution: {integrity: sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==} engines: {node: '>=18.20'} + cli-truncate@5.2.0: + resolution: {integrity: sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==} + engines: {node: '>=20'} + cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} @@ -1728,6 +1934,9 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -1750,6 +1959,10 @@ packages: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} + environment@1.1.0: + resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} + engines: {node: '>=18'} + es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} @@ -1787,6 +2000,12 @@ packages: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} + eventemitter3@4.0.7: + resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + events@3.3.0: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} @@ -1901,6 +2120,10 @@ packages: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + has-symbols@1.1.0: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} @@ -1958,6 +2181,9 @@ packages: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} + is-electron@2.2.2: + resolution: {integrity: sha512-FO/Rhvz5tuw4MCWkpMzHFKWD2LsfHzIb7i6MdPYZ/KW7AlxawyLkqdy+jPZP1WubqEADE3O4FUENlJHDfQASRg==} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -1966,6 +2192,10 @@ packages: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} + is-fullwidth-code-point@5.1.0: + resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} + engines: {node: '>=18'} + is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} @@ -1981,6 +2211,10 @@ packages: is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + is-unicode-supported@2.1.0: resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} engines: {node: '>=18'} @@ -2007,6 +2241,10 @@ packages: engines: {node: '>=6'} hasBin: true + listr2@10.2.2: + resolution: {integrity: sha512-JtNtbZj8q5BnDMR7trpwvwk3RIrANtIVzEUm8w7amp6xelLgyuq+4WZoTH913XaQAoH/cNdYhaNzBPA2U3xbDw==} + engines: {node: '>=22.13.0'} + lodash.camelcase@4.3.0: resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} @@ -2014,6 +2252,10 @@ packages: resolution: {integrity: sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==} engines: {node: '>=18'} + log-update@6.1.0: + resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} + engines: {node: '>=18'} + long@5.3.2: resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} @@ -2124,6 +2366,22 @@ packages: resolution: {integrity: sha512-84cglkRILFxdtA8hAvLNdMrtBpPNBTrQ9/ulg0FA7xLMnD6mifv+enAIeRmvtv+WgdCE+LPGOfQmtJRrVaIVhQ==} engines: {node: '>=20'} + p-finally@1.0.0: + resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==} + engines: {node: '>=4'} + + p-queue@6.6.2: + resolution: {integrity: sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==} + engines: {node: '>=8'} + + p-retry@4.6.2: + resolution: {integrity: sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==} + engines: {node: '>=8'} + + p-timeout@3.2.0: + resolution: {integrity: sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==} + engines: {node: '>=8'} + parse-passwd@1.0.0: resolution: {integrity: sha512-1Y1A//QUXEZK7YKz+rD9WydcE1+EuPr6ZBgKecAB8tmoW6UFv0NREVJe1p+jRxtThkcbbKkfwIbWJe/IeE6m2Q==} engines: {node: '>=0.10.0'} @@ -2225,10 +2483,17 @@ packages: resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} engines: {node: '>=18'} + retry@0.13.1: + resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} + engines: {node: '>= 4'} + reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + router@2.2.0: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} @@ -2297,6 +2562,14 @@ packages: sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + slice-ansi@7.1.2: + resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==} + engines: {node: '>=18'} + + slice-ansi@8.0.0: + resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==} + engines: {node: '>=20'} + ssh2@1.17.0: resolution: {integrity: sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==} engines: {node: '>=10.16.0'} @@ -2320,6 +2593,10 @@ packages: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + string-width@8.2.0: resolution: {integrity: sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==} engines: {node: '>=20'} @@ -2338,6 +2615,14 @@ packages: strnum@2.3.0: resolution: {integrity: sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==} + supermemory@4.24.2: + resolution: {integrity: sha512-DMT8xPXDXilT9+ILpdqV2029xyWhjJMEP+1UlrGv8h28wMJMgMSPWJlwbfrtbB5SI2EY/Gn1Ifg4tTYvbXW5pw==} + hasBin: true + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + tar@7.5.15: resolution: {integrity: sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ==} engines: {node: '>=18'} @@ -2411,10 +2696,18 @@ packages: engines: {node: '>= 8'} hasBin: true + wrap-ansi@10.0.0: + resolution: {integrity: sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ==} + engines: {node: '>=20'} + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} + wrap-ansi@9.0.2: + resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} + engines: {node: '>=18'} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -2476,11 +2769,78 @@ snapshots: dependencies: nanoid: 5.1.11 + '@agent-assistant/coordination@0.2.24': + dependencies: + '@agent-assistant/connectivity': 0.2.24 + nanoid: 5.1.11 + '@agent-assistant/coordination@0.4.32': dependencies: '@agent-assistant/connectivity': 0.2.24 nanoid: 5.1.11 + '@agent-assistant/core@0.2.24(@agent-assistant/traits@0.2.24)': + dependencies: + '@agent-assistant/traits': 0.2.24 + + '@agent-assistant/harness@0.4.35': + dependencies: + '@agent-assistant/connectivity': 0.2.24 + '@agent-assistant/coordination': 0.2.24 + '@agent-assistant/core': 0.2.24(@agent-assistant/traits@0.2.24) + '@agent-assistant/memory': 0.4.35 + '@agent-assistant/traits': 0.2.24 + '@agent-assistant/turn-context': 0.3.21 + '@agent-assistant/vfs': 0.2.24 + '@agent-relay/sdk': 6.3.6 + zod: 3.25.76 + transitivePeerDependencies: + - '@agent-relay/credential-proxy' + - '@anthropic-ai/claude-agent-sdk' + - '@google/adk' + - '@langchain/langgraph' + - '@mariozechner/pi-coding-agent' + - '@openai/agents' + - ai + - aws-crt + - bufferutil + - crewai + - debug + - utf-8-validate + + '@agent-assistant/memory@0.2.24': + dependencies: + '@agent-relay/memory': 4.0.40 + transitivePeerDependencies: + - '@agent-relay/credential-proxy' + - '@anthropic-ai/claude-agent-sdk' + - '@google/adk' + - '@langchain/langgraph' + - '@mariozechner/pi-coding-agent' + - '@openai/agents' + - ai + - bufferutil + - crewai + - utf-8-validate + + '@agent-assistant/memory@0.4.35': + dependencies: + '@agent-relay/memory': 6.3.6 + supermemory: 4.24.2 + transitivePeerDependencies: + - '@agent-relay/credential-proxy' + - '@anthropic-ai/claude-agent-sdk' + - '@google/adk' + - '@langchain/langgraph' + - '@mariozechner/pi-coding-agent' + - '@openai/agents' + - ai + - aws-crt + - bufferutil + - crewai + - debug + - utf-8-validate + '@agent-assistant/proactive@0.4.32': dependencies: '@agent-assistant/coordination': 0.4.32 @@ -2489,21 +2849,81 @@ snapshots: '@agent-assistant/surfaces@0.4.32': {} + '@agent-assistant/traits@0.2.24': {} + + '@agent-assistant/turn-context@0.3.21': + dependencies: + '@agent-assistant/harness': 0.4.35 + '@agent-assistant/memory': 0.2.24 + '@agent-assistant/traits': 0.2.24 + transitivePeerDependencies: + - '@agent-relay/credential-proxy' + - '@anthropic-ai/claude-agent-sdk' + - '@google/adk' + - '@langchain/langgraph' + - '@mariozechner/pi-coding-agent' + - '@openai/agents' + - ai + - aws-crt + - bufferutil + - crewai + - debug + - utf-8-validate + + '@agent-assistant/turn-context@0.4.35': + dependencies: + '@agent-assistant/harness': 0.4.35 + '@agent-assistant/memory': 0.2.24 + '@agent-assistant/traits': 0.2.24 + '@agent-assistant/vfs': 0.4.35 + transitivePeerDependencies: + - '@agent-relay/credential-proxy' + - '@anthropic-ai/claude-agent-sdk' + - '@google/adk' + - '@langchain/langgraph' + - '@mariozechner/pi-coding-agent' + - '@openai/agents' + - ai + - aws-crt + - bufferutil + - crewai + - debug + - utf-8-validate + + '@agent-assistant/vfs@0.2.24': {} + + '@agent-assistant/vfs@0.4.35': {} + '@agent-relay/broker-darwin-arm64@10.2.0': optional: true + '@agent-relay/broker-darwin-arm64@6.3.6': + optional: true + '@agent-relay/broker-darwin-x64@10.2.0': optional: true + '@agent-relay/broker-darwin-x64@6.3.6': + optional: true + '@agent-relay/broker-linux-arm64@10.2.0': optional: true + '@agent-relay/broker-linux-arm64@6.3.6': + optional: true + '@agent-relay/broker-linux-x64@10.2.0': optional: true + '@agent-relay/broker-linux-x64@6.3.6': + optional: true + '@agent-relay/broker-win32-x64@10.2.0': optional: true + '@agent-relay/broker-win32-x64@6.3.6': + optional: true + '@agent-relay/cloud@10.2.0': dependencies: '@agent-relay/config': 10.2.0 @@ -2515,6 +2935,17 @@ snapshots: transitivePeerDependencies: - aws-crt + '@agent-relay/cloud@6.3.6': + dependencies: + '@agent-relay/config': 6.3.6 + '@aws-sdk/client-s3': 3.1020.0 + ignore: 7.0.5 + tar: 7.5.15 + optionalDependencies: + ssh2: 1.17.0 + transitivePeerDependencies: + - aws-crt + '@agent-relay/cloud@8.7.0': dependencies: '@agent-relay/config': 8.7.0 @@ -2531,6 +2962,16 @@ snapshots: zod: 3.25.76 zod-to-json-schema: 3.25.2(zod@3.25.76) + '@agent-relay/config@4.0.40': + dependencies: + zod: 3.25.76 + zod-to-json-schema: 3.25.2(zod@3.25.76) + + '@agent-relay/config@6.3.6': + dependencies: + zod: 3.25.76 + zod-to-json-schema: 3.25.2(zod@3.25.76) + '@agent-relay/config@8.7.0': dependencies: zod: 3.25.76 @@ -2558,6 +2999,10 @@ snapshots: - bufferutil - utf-8-validate + '@agent-relay/github-primitive@6.3.6': + dependencies: + '@agent-relay/workflow-types': 6.3.6 + '@agent-relay/harness-driver@10.2.0': dependencies: '@agent-relay/sdk': 10.2.0 @@ -2581,10 +3026,149 @@ snapshots: - bufferutil - utf-8-validate + '@agent-relay/hooks@4.0.40': + dependencies: + '@agent-relay/config': 4.0.40 + '@agent-relay/sdk': 4.0.40 + '@agent-relay/trajectory': 4.0.40 + transitivePeerDependencies: + - '@agent-relay/credential-proxy' + - '@anthropic-ai/claude-agent-sdk' + - '@google/adk' + - '@langchain/langgraph' + - '@mariozechner/pi-coding-agent' + - '@openai/agents' + - ai + - bufferutil + - crewai + - utf-8-validate + + '@agent-relay/hooks@6.3.6': + dependencies: + '@agent-relay/config': 6.3.6 + '@agent-relay/sdk': 6.3.6 + '@agent-relay/trajectory': 6.3.6 + transitivePeerDependencies: + - '@agent-relay/credential-proxy' + - '@anthropic-ai/claude-agent-sdk' + - '@google/adk' + - '@langchain/langgraph' + - '@mariozechner/pi-coding-agent' + - '@openai/agents' + - ai + - aws-crt + - bufferutil + - crewai + - debug + - utf-8-validate + + '@agent-relay/memory@4.0.40': + dependencies: + '@agent-relay/hooks': 4.0.40 + transitivePeerDependencies: + - '@agent-relay/credential-proxy' + - '@anthropic-ai/claude-agent-sdk' + - '@google/adk' + - '@langchain/langgraph' + - '@mariozechner/pi-coding-agent' + - '@openai/agents' + - ai + - bufferutil + - crewai + - utf-8-validate + + '@agent-relay/memory@6.3.6': + dependencies: + '@agent-relay/hooks': 6.3.6 + transitivePeerDependencies: + - '@agent-relay/credential-proxy' + - '@anthropic-ai/claude-agent-sdk' + - '@google/adk' + - '@langchain/langgraph' + - '@mariozechner/pi-coding-agent' + - '@openai/agents' + - ai + - aws-crt + - bufferutil + - crewai + - debug + - utf-8-validate + '@agent-relay/sdk@10.2.0': dependencies: '@relaycast/sdk': 6.0.5 + '@agent-relay/sdk@4.0.40': + dependencies: + '@agent-relay/config': 4.0.40 + '@relaycast/sdk': 1.2.0 + '@relayfile/sdk': 0.7.40 + '@sinclair/typebox': 0.34.52 + agent-trajectories: 0.5.9 + chalk: 4.1.2 + ignore: 7.0.5 + listr2: 10.2.2 + tar: 7.5.15 + ws: 8.21.0 + yaml: 2.9.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@agent-relay/sdk@6.3.6': + dependencies: + '@agent-relay/cloud': 6.3.6 + '@agent-relay/config': 6.3.6 + '@agent-relay/github-primitive': 6.3.6 + '@agent-relay/slack-primitive': 6.3.6 + '@agent-relay/workflow-types': 6.3.6 + '@agentworkforce/harness-kit': 0.11.0 + '@agentworkforce/workload-router': 0.11.0 + '@relaycast/sdk': 1.2.0 + '@relayfile/sdk': 0.7.40 + '@sinclair/typebox': 0.34.52 + agent-trajectories: 0.5.9 + chalk: 4.1.2 + ignore: 7.0.5 + listr2: 10.2.2 + tar: 7.5.15 + ws: 8.21.0 + yaml: 2.9.0 + optionalDependencies: + '@agent-relay/broker-darwin-arm64': 6.3.6 + '@agent-relay/broker-darwin-x64': 6.3.6 + '@agent-relay/broker-linux-arm64': 6.3.6 + '@agent-relay/broker-linux-x64': 6.3.6 + '@agent-relay/broker-win32-x64': 6.3.6 + transitivePeerDependencies: + - aws-crt + - bufferutil + - debug + - utf-8-validate + + '@agent-relay/slack-primitive@6.3.6': + dependencies: + '@agent-relay/workflow-types': 6.3.6 + '@slack/web-api': 7.19.0 + transitivePeerDependencies: + - debug + + '@agent-relay/trajectory@4.0.40': + dependencies: + '@agent-relay/config': 4.0.40 + + '@agent-relay/trajectory@6.3.6': + dependencies: + '@agent-relay/config': 6.3.6 + + '@agent-relay/workflow-types@6.3.6': {} + + '@agentworkforce/harness-kit@0.11.0': + dependencies: + '@agentworkforce/workload-router': 0.11.0 + + '@agentworkforce/workload-router@0.11.0': {} + '@aws-crypto/crc32@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 @@ -3790,11 +4374,20 @@ snapshots: dependencies: zod: 4.4.3 + '@relaycast/sdk@1.2.0': + dependencies: + '@relaycast/types': 1.2.0 + zod: 4.4.3 + '@relaycast/sdk@6.0.5': dependencies: '@relaycast/types': 6.0.5 zod: 4.4.3 + '@relaycast/types@1.2.0': + dependencies: + zod: 4.4.3 + '@relaycast/types@6.0.5': dependencies: zod: 4.4.3 @@ -3861,6 +4454,31 @@ snapshots: '@scalar/helpers': 0.5.1 '@scalar/openapi-types': 0.7.0 + '@sinclair/typebox@0.34.52': {} + + '@slack/logger@4.0.1': + dependencies: + '@types/node': 22.19.15 + + '@slack/types@2.22.0': {} + + '@slack/web-api@7.19.0': + dependencies: + '@slack/logger': 4.0.1 + '@slack/types': 2.22.0 + '@types/node': 22.19.15 + '@types/retry': 0.12.0 + axios: 1.16.0 + eventemitter3: 5.0.4 + form-data: 4.0.5 + is-electron: 2.2.2 + is-stream: 2.0.1 + p-queue: 6.6.2 + p-retry: 4.6.2 + retry: 0.13.1 + transitivePeerDependencies: + - debug + '@smithy/config-resolver@4.5.1': dependencies: '@smithy/core': 3.24.1 @@ -4081,6 +4699,8 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/retry@0.12.0': {} + '@types/semver@7.7.1': {} '@types/validate-npm-package-name@4.0.2': {} @@ -4102,6 +4722,12 @@ snapshots: commander: 12.1.0 zod: 3.25.76 + agent-trajectories@0.5.9: + dependencies: + '@clack/prompts': 0.7.0 + commander: 12.1.0 + zod: 3.25.76 + ajv-formats@3.0.1(ajv@8.20.0): optionalDependencies: ajv: 8.20.0 @@ -4113,6 +4739,10 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 + ansi-escapes@7.3.0: + dependencies: + environment: 1.1.0 + ansi-regex@5.0.1: {} ansi-regex@6.2.2: {} @@ -4121,6 +4751,8 @@ snapshots: dependencies: color-convert: 2.0.1 + ansi-styles@6.2.3: {} + asn1@0.2.6: dependencies: safer-buffer: 2.1.2 @@ -4195,6 +4827,11 @@ snapshots: call-bind-apply-helpers: 1.0.2 get-intrinsic: 1.3.0 + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + chalk@5.6.2: {} cheerio-select@2.1.0: @@ -4230,6 +4867,11 @@ snapshots: cli-spinners@3.4.0: {} + cli-truncate@5.2.0: + dependencies: + slice-ansi: 8.0.0 + string-width: 8.2.0 + cliui@8.0.1: dependencies: string-width: 4.2.3 @@ -4323,6 +4965,8 @@ snapshots: ee-first@1.1.1: {} + emoji-regex@10.6.0: {} + emoji-regex@8.0.0: {} encodeurl@2.0.0: {} @@ -4338,6 +4982,8 @@ snapshots: entities@7.0.1: {} + environment@1.1.0: {} + es-define-property@1.0.1: {} es-errors@1.3.0: {} @@ -4417,6 +5063,10 @@ snapshots: etag@1.8.1: {} + eventemitter3@4.0.7: {} + + eventemitter3@5.0.4: {} + events@3.3.0: {} eventsource-parser@3.0.8: {} @@ -4565,6 +5215,8 @@ snapshots: gopd@1.2.0: {} + has-flag@4.0.0: {} + has-symbols@1.1.0: {} has-tostringtag@1.0.2: @@ -4621,10 +5273,16 @@ snapshots: ipaddr.js@1.9.1: {} + is-electron@2.2.2: {} + is-extglob@2.1.1: {} is-fullwidth-code-point@3.0.0: {} + is-fullwidth-code-point@5.1.0: + dependencies: + get-east-asian-width: 1.5.0 + is-glob@4.0.3: dependencies: is-extglob: 2.1.1 @@ -4635,6 +5293,8 @@ snapshots: is-promise@4.0.0: {} + is-stream@2.0.1: {} + is-unicode-supported@2.1.0: {} isexe@2.0.0: {} @@ -4651,6 +5311,14 @@ snapshots: json5@2.2.3: {} + listr2@10.2.2: + dependencies: + cli-truncate: 5.2.0 + eventemitter3: 5.0.4 + log-update: 6.1.0 + rfdc: 1.4.1 + wrap-ansi: 10.0.0 + lodash.camelcase@4.3.0: {} log-symbols@7.0.1: @@ -4658,6 +5326,14 @@ snapshots: is-unicode-supported: 2.1.0 yoctocolors: 2.1.2 + log-update@6.1.0: + dependencies: + ansi-escapes: 7.3.0 + cli-cursor: 5.0.0 + slice-ansi: 7.1.2 + strip-ansi: 7.2.0 + wrap-ansi: 9.0.2 + long@5.3.2: {} lru-cache@11.3.6: {} @@ -4745,6 +5421,22 @@ snapshots: stdin-discarder: 0.3.2 string-width: 8.2.0 + p-finally@1.0.0: {} + + p-queue@6.6.2: + dependencies: + eventemitter3: 4.0.7 + p-timeout: 3.2.0 + + p-retry@4.6.2: + dependencies: + '@types/retry': 0.12.0 + retry: 0.13.1 + + p-timeout@3.2.0: + dependencies: + p-finally: 1.0.0 + parse-passwd@1.0.0: {} parse5-htmlparser2-tree-adapter@7.1.0: @@ -4846,8 +5538,12 @@ snapshots: onetime: 7.0.0 signal-exit: 4.1.0 + retry@0.13.1: {} + reusify@1.1.0: {} + rfdc@1.4.1: {} + router@2.2.0: dependencies: debug: 4.4.3 @@ -4937,6 +5633,16 @@ snapshots: sisteransi@1.0.5: {} + slice-ansi@7.1.2: + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + + slice-ansi@8.0.0: + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + ssh2@1.17.0: dependencies: asn1: 0.2.6 @@ -4963,6 +5669,12 @@ snapshots: is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.5.0 + strip-ansi: 7.2.0 + string-width@8.2.0: dependencies: get-east-asian-width: 1.5.0 @@ -4982,6 +5694,12 @@ snapshots: strnum@2.3.0: {} + supermemory@4.24.2: {} + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + tar@7.5.15: dependencies: '@isaacs/fs-minipass': 4.0.1 @@ -5048,12 +5766,24 @@ snapshots: dependencies: isexe: 2.0.0 + wrap-ansi@10.0.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 8.2.0 + strip-ansi: 7.2.0 + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 string-width: 4.2.3 strip-ansi: 6.0.1 + wrap-ansi@9.0.2: + dependencies: + ansi-styles: 6.2.3 + string-width: 7.2.0 + strip-ansi: 7.2.0 + wrappy@1.0.2: {} ws@8.21.0: {} From f51104a6338fdc4510e3c1133d7d009f49cbea9c Mon Sep 17 00:00:00 2001 From: Ricky Schema Cascade Date: Fri, 24 Jul 2026 14:05:47 +0200 Subject: [PATCH 4/5] fix: isolate conversation keys and order timestamps --- packages/turn-kit/CHANGELOG.md | 2 ++ packages/turn-kit/README.md | 5 +++-- packages/turn-kit/src/memory.ts | 12 +++++++++--- packages/turn-kit/src/turn-kit.test.ts | 14 ++++++++++++++ 4 files changed, 28 insertions(+), 5 deletions(-) diff --git a/packages/turn-kit/CHANGELOG.md b/packages/turn-kit/CHANGELOG.md index 914a25e8..ee63aa83 100644 --- a/packages/turn-kit/CHANGELOG.md +++ b/packages/turn-kit/CHANGELOG.md @@ -22,3 +22,5 @@ file. harness projection. - Align deterministic context blocks with the Agent Assistant prepared-context shape (`id`, `label`, `content`, source/category metadata). +- Encode conversation-key components independently to prevent delimiter + collisions, and sort timestamped history by parsed instants. diff --git a/packages/turn-kit/README.md b/packages/turn-kit/README.md index 3ac8af99..cec072d1 100644 --- a/packages/turn-kit/README.md +++ b/packages/turn-kit/README.md @@ -184,8 +184,9 @@ Use the transport's stable conversation boundary: - Slack: channel id plus root thread timestamp. - Relay inbox: sender plus any application thread/correlation id. -`conversationKey(root, thread)` composes the transport-local id, and -`conversationTag(namespace, conversation)` adds namespace and transport +`conversationKey(root, thread)` percent-encodes each component before joining +them so delimiter-bearing provider ids cannot collide. +`conversationTag(namespace, conversation)` then adds namespace and transport isolation. ## Partial adoption diff --git a/packages/turn-kit/src/memory.ts b/packages/turn-kit/src/memory.ts index d0694591..f9f5b770 100644 --- a/packages/turn-kit/src/memory.ts +++ b/packages/turn-kit/src/memory.ts @@ -10,8 +10,14 @@ const DEFAULT_SCOPE = 'workspace'; export function conversationKey(root: string, thread?: string | number): string { const base = requiredIdentityPart(root, 'conversation root'); - if (thread === undefined || thread === null || String(thread).trim() === '') return base; - return `${base}:${requiredIdentityPart(String(thread), 'conversation thread')}`; + const encodedBase = encodeURIComponent(base); + if (thread === undefined || thread === null || String(thread).trim() === '') { + return encodedBase; + } + const encodedThread = encodeURIComponent( + requiredIdentityPart(String(thread), 'conversation thread') + ); + return `${encodedBase}:${encodedThread}`; } export function conversationTag(namespace: string, conversation: TurnConversation): string { @@ -70,7 +76,7 @@ export function normalizeTurnHistory( if (entries.length > 0 && entries.every((entry) => validDate(entry.createdAt))) { entries.sort((left, right) => - String(left.createdAt).localeCompare(String(right.createdAt)) + Date.parse(String(left.createdAt)) - Date.parse(String(right.createdAt)) ); } else if (recallOrder === 'newest-first') { entries.reverse(); diff --git a/packages/turn-kit/src/turn-kit.test.ts b/packages/turn-kit/src/turn-kit.test.ts index 20ceed6e..005403ec 100644 --- a/packages/turn-kit/src/turn-kit.test.ts +++ b/packages/turn-kit/src/turn-kit.test.ts @@ -42,6 +42,12 @@ test('conversation identity keeps transports and threads isolated', () => { conversationTag('joke-bot', { transport: 'telegram', id: '8587' }), conversationTag('joke-bot', { transport: 'slack', id: '8587' }) ); + assert.equal(conversationKey('room:topic'), 'room%3Atopic'); + assert.equal(conversationKey('room', 'topic'), 'room:topic'); + assert.notEqual( + conversationKey('room:topic'), + conversationKey('room', 'topic') + ); assert.throws( () => conversationTag('Not Valid', { transport: 'telegram', id: '1' }), /lowercase slug/ @@ -57,6 +63,14 @@ test('timestamped memory is always returned oldest first', () => { assert.deepEqual(history.map((entry) => entry.content), ['first', 'second', 'third']); }); +test('timestamped memory sorts by instant when offsets differ', () => { + const history = normalizeTurnHistory([ + memoryItem('later', '2026-07-24T10:30:00Z'), + memoryItem('earlier', '2026-07-24T12:00:00+02:00') + ]); + assert.deepEqual(history.map((entry) => entry.content), ['earlier', 'later']); +}); + test('timestamp-less cloud recall defaults from newest-first to chronological', () => { assert.deepEqual( normalizeTurnHistory(['newest', 'middle', 'oldest']).map((entry) => entry.content), From 8877720eb01ff02d7e2b70ab4ab2918873b02445 Mon Sep 17 00:00:00 2001 From: Ricky Schema Cascade Date: Fri, 24 Jul 2026 18:15:46 +0200 Subject: [PATCH 5/5] fix: fail closed on turn isolation dependencies --- examples/turn-agent/README.md | 8 ++-- examples/turn-agent/agent.ts | 63 ++++++++++++++++++++++---- packages/runtime/src/ctx.test.ts | 32 +++++++++++++ packages/runtime/src/ctx.ts | 9 ++-- packages/runtime/src/types.ts | 5 ++ packages/turn-kit/src/memory.ts | 3 +- packages/turn-kit/src/turn-kit.test.ts | 23 ++++++++++ 7 files changed, 128 insertions(+), 15 deletions(-) diff --git a/examples/turn-agent/README.md b/examples/turn-agent/README.md index 02150a25..57f1ceee 100644 --- a/examples/turn-agent/README.md +++ b/examples/turn-agent/README.md @@ -4,9 +4,11 @@ Minimal runnable Relay channel agent demonstrating `@agentworkforce/turn-kit`. The persona explicitly enables workspace memory. The handler derives a stable -conversation id from the Relay channel + thread, receives chronological -history, sends one direct-model reply, requires a Relay delivery receipt, and -only then saves the turn. +conversation id from the Relay channel + thread (or the peer identity for a +direct message), receives chronological history, sends one direct-model reply +to the originating channel or peer, requires a Relay delivery receipt, and only +then saves the turn. Direct messages fail closed when their peer identity is +unavailable, so separate senders cannot share workspace-scoped history. ```bash agentworkforce deploy ./examples/turn-agent/persona.ts --mode cloud diff --git a/examples/turn-agent/agent.ts b/examples/turn-agent/agent.ts index ee5ee4ce..30b4d2ad 100644 --- a/examples/turn-agent/agent.ts +++ b/examples/turn-agent/agent.ts @@ -25,11 +25,19 @@ export default defineAgent({ const expanded = await event.expand('full'); const input = messageText(expanded.data); if (!input) return; + const directMessage = isDirectMessageChannel(event.channel); + const peer = directMessage ? relayPeer(event, expanded.data) : undefined; + if (directMessage && !peer) { + ctx.log('warn', 'turn-agent.direct-peer-unavailable', { channel: event.channel }); + return; + } await turns.run(ctx, { conversation: { transport: 'relay', - id: conversationKey(event.channel, event.threadId) + id: directMessage + ? conversationKey(event.channel, peer) + : conversationKey(event.channel, event.threadId) }, input, respond: async ({ history }) => @@ -43,24 +51,63 @@ export default defineAgent({ ].filter(Boolean).join('\n\n'), { maxTokens: 300 } ), - deliver: (reply) => ctx.relay.post(event.channel!, reply), + deliver: (reply) => + directMessage + ? ctx.relay.dm(peer!, reply) + : ctx.relay.post(event.channel!, reply), confirmDelivery: (receipt) => receipt.ok }); } }); +function isDirectMessageChannel(channel: string): boolean { + return channel === 'dm' || channel.startsWith('dm:'); +} + +function relayPeer(event: AgentEvent, data: unknown): string | undefined { + const eventSummary = record((event as { summary?: unknown }).summary); + const full = record(data); + const candidates = [ + record(eventSummary?.actor), + record(full?.from), + record(full?.actor), + record(record(full?.message)?.from), + record(full?.message) + ]; + for (const candidate of candidates) { + const identity = + text(candidate?.id) ?? + text(candidate?.agentId) ?? + text(candidate?.agent_id) ?? + text(candidate?.name) ?? + text(candidate?.displayName); + if (identity) return identity; + } + return undefined; +} + function messageText(value: unknown): string { - if (!value || typeof value !== 'object' || Array.isArray(value)) return ''; - const record = value as Record; + const recordValue = record(value); + if (!recordValue) return ''; const nested = - record.message && typeof record.message === 'object' && !Array.isArray(record.message) - ? record.message as Record + recordValue.message && typeof recordValue.message === 'object' && !Array.isArray(recordValue.message) + ? recordValue.message as Record : {}; return ( - typeof record.text === 'string' - ? record.text + typeof recordValue.text === 'string' + ? recordValue.text : typeof nested.text === 'string' ? nested.text : '' ).trim(); } + +function record(value: unknown): Record | undefined { + return value && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : undefined; +} + +function text(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value.trim() : undefined; +} diff --git a/packages/runtime/src/ctx.test.ts b/packages/runtime/src/ctx.test.ts index 93489123..28d70bab 100644 --- a/packages/runtime/src/ctx.test.ts +++ b/packages/runtime/src/ctx.test.ts @@ -543,6 +543,34 @@ test('ctx.memory.recall falls back to [] on network failure', async () => { ); }); +test('ctx.memory.recall rejects backend failures when failOnError is set', async () => { + await withEnv( + { + WORKFORCE_CLOUD_URL: 'https://cloud.example.test', + WORKFORCE_AGENT_TOKEN: 'agent-token' + }, + async () => { + await withFetch(async () => { + throw new Error('offline'); + }, async () => { + const ctx = ctxFor({ ...basePersona, memory: true }); + await assert.rejects( + () => ctx.memory.recall('anything', { failOnError: true }), + /offline/ + ); + }); + + await withFetch(async () => jsonResponse({ error: 'unavailable' }, { status: 503 }), async () => { + const ctx = ctxFor({ ...basePersona, memory: true }); + await assert.rejects( + () => ctx.memory.recall('anything', { failOnError: true }), + /HTTP 503/ + ); + }); + } + ); +}); + test('ctx.memory logs a bounded timeout when cloud memory fetch aborts', async () => { await withEnv( { @@ -602,6 +630,10 @@ test('ctx.memory stays a safe no-op when cloud auth is absent', async () => { const ctx = ctxFor({ ...basePersona, memory: true }); assert.equal(await ctx.memory.save('quiet'), undefined); assert.deepEqual(await ctx.memory.recall('quiet'), []); + await assert.rejects( + () => ctx.memory.recall('quiet', { failOnError: true }), + /ctx\.memory is unavailable/ + ); }); assert.equal(fetchCalled, false); } diff --git a/packages/runtime/src/ctx.ts b/packages/runtime/src/ctx.ts index 5c33f128..58cc1fa8 100644 --- a/packages/runtime/src/ctx.ts +++ b/packages/runtime/src/ctx.ts @@ -74,7 +74,10 @@ const NOOP_MEMORY: MemoryContext = { async save() { /* memory disabled (persona.memory unset) — saves silently no-op */ }, - async recall() { + async recall(_query, opts) { + if (opts?.failOnError) { + throw new Error('ctx.memory is unavailable: enable persona memory and connect cloud memory credentials.'); + } return []; } }; @@ -407,13 +410,13 @@ function createCloudMemoryContext(args: { headers: { authorization: `Bearer ${args.agentToken}` } }); if (!response.ok) { - args.log('warn', 'memory.recall.failed', { status: response.status }); - return []; + throw new Error(`cloud memory recall failed with HTTP ${response.status}`); } const body = await response.json().catch(() => ({})) as { items?: unknown }; return normalizeMemoryItems(body.items); } catch (err) { args.log('warn', 'memory.recall.failed', { error: memoryFetchErrorMessage(err) }); + if (opts?.failOnError) throw err; return []; } } diff --git a/packages/runtime/src/types.ts b/packages/runtime/src/types.ts index b8649a08..fa592ee9 100644 --- a/packages/runtime/src/types.ts +++ b/packages/runtime/src/types.ts @@ -264,6 +264,11 @@ export interface MemoryRecallOptions { scope?: PersonaMemoryScope; scopes?: PersonaMemoryScope[]; tags?: string[]; + /** + * Reject when the backing memory service is unavailable instead of + * degrading to an empty result. + */ + failOnError?: boolean; } export interface MemoryItem { diff --git a/packages/turn-kit/src/memory.ts b/packages/turn-kit/src/memory.ts index f9f5b770..1f88ef1b 100644 --- a/packages/turn-kit/src/memory.ts +++ b/packages/turn-kit/src/memory.ts @@ -38,7 +38,8 @@ export async function recallTurnHistory( recalled = await ctx.memory.recall(options.query ?? 'recent conversation turns', { tags: [tag], limit, - scope: options.scope ?? DEFAULT_SCOPE + scope: options.scope ?? DEFAULT_SCOPE, + failOnError: options.required }); } catch (error) { ctx.log?.('warn', 'turn-kit.memory-recall-failed', { error: String(error) }); diff --git a/packages/turn-kit/src/turn-kit.test.ts b/packages/turn-kit/src/turn-kit.test.ts index 005403ec..7e49179e 100644 --- a/packages/turn-kit/src/turn-kit.test.ts +++ b/packages/turn-kit/src/turn-kit.test.ts @@ -313,6 +313,29 @@ test('missing memory save receipt is visible without failing a delivered chat tu ); }); +test('required memory recall asks the runtime adapter to fail on backend errors', async () => { + let recallOptions: Parameters[1]; + const ctx = fakeCtx({ + recall: async (_query, options) => { + recallOptions = options; + return []; + } + }); + const result = await createTurnRunner({ + namespace: 'memory-required', + memory: { required: true } + }).run(ctx, { + conversation: { transport: 'relay', id: 'peer' }, + input: 'hello', + respond: () => 'Hi', + deliver: async () => ({ ok: true }), + confirmDelivery: (receipt) => receipt.ok + }); + + assert.equal(result.reply, 'Hi'); + assert.equal(recallOptions?.failOnError, true); +}); + function memoryItem(content: string, createdAt: string): MemoryItem { return { id: `mem-${createdAt}`,