Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -712,13 +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',
];
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/verify-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
3 changes: 2 additions & 1 deletion examples/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
16 changes: 16 additions & 0 deletions examples/turn-agent/README.md
Original file line number Diff line number Diff line change
@@ -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.
66 changes: 66 additions & 0 deletions examples/turn-agent/agent.ts
Original file line number Diff line number Diff line change
@@ -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)
},
Comment on lines +30 to +33

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Include the direct-message sender in the conversation key

For unthreaded @self Relay inbox messages, this produces the same relay:dm conversation id for every sender: the runtime maps a missing channel to dm and leaves threadId absent. Since this example uses workspace-scoped memory, a later direct message from another user recalls the prior user's input/reply and places it in that user's model prompt. Derive the key from the inbound peer/sender (or require a per-peer thread id) before invoking the runner.

Useful? React with 👍 / 👎.

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<string, unknown>;
const nested =
record.message && typeof record.message === 'object' && !Array.isArray(record.message)
? record.message as Record<string, unknown>
: {};
return (
typeof record.text === 'string'
? record.text
: typeof nested.text === 'string'
? nested.text
: ''
).trim();
}
18 changes: 18 additions & 0 deletions examples/turn-agent/persona.ts
Original file line number Diff line number Diff line change
@@ -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'
});
26 changes: 26 additions & 0 deletions packages/turn-kit/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# 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.
- 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).
- Encode conversation-key components independently to prevent delimiter
collisions, and sort timestamped history by parsed instants.
196 changes: 196 additions & 0 deletions packages/turn-kit/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
# @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 }) => ({
id: 'open-work',
label: 'Open work',
content: await ctx.files.read('/my-agent/open-work.json'),
source: 'relayfile',
category: 'workspace'
})
})
]
});

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
});
```

## 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:

```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)` 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

Handlers do not have to use the full runner. `recallTurnHistory()`,
`rememberTurn()`, `collectTurnContext()`, and the conversation helpers are
public for agents with a custom lifecycle.
Loading
Loading