Skip to content
Merged
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
18 changes: 18 additions & 0 deletions examples/turn-agent/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# 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 (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
```

Real agents can add deterministic `defineTurnContext()` providers and interim
`acknowledge()` messages without changing that lifecycle.
113 changes: 113 additions & 0 deletions examples/turn-agent/agent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
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;
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: directMessage
? conversationKey(event.channel, peer)
: conversationKey(event.channel, event.threadId)
},
Comment thread
khaliqgant marked this conversation as resolved.
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) =>
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 {
const recordValue = record(value);
if (!recordValue) return '';
const nested =
recordValue.message && typeof recordValue.message === 'object' && !Array.isArray(recordValue.message)
? recordValue.message as Record<string, unknown>
: {};
return (
typeof recordValue.text === 'string'
? recordValue.text
: typeof nested.text === 'string'
? nested.text
: ''
).trim();
}

function record(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: undefined;
}

function text(value: unknown): string | undefined {
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
}
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'
});
32 changes: 32 additions & 0 deletions packages/runtime/src/ctx.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
{
Expand Down Expand Up @@ -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);
}
Expand Down
9 changes: 6 additions & 3 deletions packages/runtime/src/ctx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 [];
}
};
Expand Down Expand Up @@ -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 [];
}
}
Expand Down
5 changes: 5 additions & 0 deletions packages/runtime/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
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.
Loading
Loading