Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
500a179
Add teammate discovery and persona cards
Jul 21, 2026
c1cb06f
Harden teammate replies and persona registration
Jul 21, 2026
98d10dd
Strengthen teammate round-trip E2E
Jul 21, 2026
cfa7263
Harden teammate contract verification
Jul 22, 2026
36064ed
Align persona fallback with upstream contract
Jul 22, 2026
5a53da6
fix(persona): use canonical agent card mapper
khaliqgant Aug 17, 2026
cc9436c
fix(fleet): match teammate replies to the backend's real sender
khaliqgant Aug 22, 2026
8da208b
fix(fleet): resolve the real sender and claim the resolved teammate
khaliqgant Aug 22, 2026
8790bc3
fix(fleet): await transport readiness, quarantine timed-out asks, sco…
khaliqgant Aug 22, 2026
3fb5783
fix(fleet): canonicalize teammate claim identities (#139)
khaliqgant Aug 23, 2026
27eebf1
fix(fleet): expose teammate tools and retry card publication (#139)
khaliqgant Aug 23, 2026
7d377a8
fix(fleet): preserve persona registration and worker auth (#139)
khaliqgant Aug 23, 2026
0d5a43b
fix(fleet): drain late teammate replies before retry (#139)
khaliqgant Aug 23, 2026
59218f2
fix(fleet): keep uncorrelated timeouts quarantined (#139)
khaliqgant Aug 23, 2026
6e70e18
fix(fleet): distinguish definitive delivery rejection (#139)
khaliqgant Aug 23, 2026
3a3f1b2
fix(fleet): ignore replies received before send (#139)
khaliqgant Aug 23, 2026
21d9282
fix(fleet): unify teammate reply identity claims (#139)
khaliqgant Aug 23, 2026
a53f78d
fix(fleet): claim every accepted reply identity (#139)
khaliqgant Aug 23, 2026
b915952
fix(fleet): share ask claims across relay clients
khaliqgant Aug 23, 2026
b37fa85
fix(fleet): fail closed on unknown relay streams
khaliqgant Aug 23, 2026
0075364
fix(fleet): share teammate claims across broker clients
khaliqgant Aug 23, 2026
1d09656
fix(fleet): canonicalize connection stream paths
khaliqgant Aug 24, 2026
a4cfb87
Merge remote-tracking branch 'origin/main' into factory/139-agentwork…
khaliqgant Aug 24, 2026
7d33932
fix(fleet): await teammate socket handshake
khaliqgant Aug 24, 2026
f40b482
fix(fleet): canonicalize missing relay receipt parents
khaliqgant Aug 24, 2026
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
10 changes: 10 additions & 0 deletions .agentworkforce/agents/factory-feature-guardian/persona.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,16 @@
"intent": "relay-orchestrator",
"tags": ["factory", "verification", "proactive", "health", "release-safety", "durability"],
"description": "Traverses the Factory feature catalog hourly to exercise the manifest/model/state chain with log-only healthy evidence, without claiming per-feature E2E verification; genuine run failures surface through deployment monitoring, while scoped Slack response events retain exact-revision incident handling.",
"skills": [
{
"id": "factory-feature-verification",
"source": ".agentworkforce/features/verify/procedures.md",
"description": "Verify Factory features against their named end-to-end procedures and supported safety boundaries."
}
],
"relay": {
"agentName": "factory-feature-guardian"
},
"cloud": true,
"harness": "opencode",
"model": "deepseek-v4-flash-free",
Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -526,6 +526,15 @@ The definition reads its node config from `./factory.node.json` (or
mapped repo is advertised as a `repo:<label>` tag so placement can route
repo-scoped spawns to it. Spawns for unadvertised paths are refused on the node.

The default `@agent-relay/factory/node` export is a generic worker-machine
definition; it does not host a persona and therefore does not publish an agent
card. An application that intentionally couples a persona to a Factory node
uses the exported `createFactoryNodeDefinition({ persona })` and
`startFactoryNode({ cardPublisher })` runtime. `startFactoryNode` owns the
online-registration edge and publishes the canonical card there; cloud personas
such as the checked-in `factory-feature-guardian` continue to use the Agent
Workforce cloud-persona deployment path described above.

### Tailnet live previews

Factory can attach an issue-lifetime [Tailscale Serve](https://tailscale.com/docs/features/tailscale-serve)
Expand Down
49 changes: 43 additions & 6 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -102,9 +102,12 @@
"@agent-relay/harness-driver": "^11.6.9",
"@agent-relay/integration-prompts": "^11.6.9",
"@agent-relay/sdk": "^11.6.9",
"@modelcontextprotocol/sdk": "1.29.0",
"@agentworkforce/delivery": "^4.1.23",
"@agentworkforce/persona-kit": "^4.1.43",
"@agentworkforce/review-kit": "^4.1.34",
"@agentworkforce/runtime": "^4.1.23",
"@relaycast/a2a": "^6.2.0",
"@relayfile/relay-helpers": "^0.4.6",
"@relayfile/sdk": "0.10.34",
"@relayflows/core": "^1.0.3",
Expand All @@ -118,7 +121,6 @@
"listr2": "9.0.5"
},
"devDependencies": {
"@agentworkforce/persona-kit": "^4.1.23",
"@types/node": "^22.10.2",
"@types/proper-lockfile": "^4.1.4",
"esbuild": "^0.28.1",
Expand Down
5 changes: 5 additions & 0 deletions src/cli/fleet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2875,6 +2875,11 @@ async function readFactoryVersion(): Promise<string> {
}

export async function main(argv = process.argv.slice(2)): Promise<void> {
if (argv[0] === 'teammate-mcp') {
const { startFactoryTeammateMcpStdio } = await import('./teammate-mcp')
await startFactoryTeammateMcpStdio()
return
}
const code = await runFleetCli(argv)
process.exitCode = code
}
87 changes: 87 additions & 0 deletions src/cli/teammate-mcp.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
import { describe, expect, it } from 'vitest'

import { FakeFleetClient } from '../testing/fakes'
import { createFactoryTeammateMcpServer } from './teammate-mcp'

describe('Factory teammate MCP', () => {
it('exposes discover and bounded ask to the spawned worker through its injected MCP server', async () => {
const fleet = new FakeFleetClient()
fleet.teammates.push({
name: 'infra-agent',
address: 'infra-agent',
description: 'Infrastructure specialist',
skills: [{ id: 'infra-watch', name: 'Infra Watch' }],
tags: ['operations'],
kind: 'native',
url: 'relay://infra-agent',
})
fleet.waitForInjected = async (input) => {
fleet.messages.push(input)
queueMicrotask(() => fleet.emitAgentMessage({
from: input.to,
target: 'ar-139-impl-factory',
body: 'The deployment heartbeat is frozen.',
}))
return { eventId: 'event-1', targets: [input.to] }
}

const { server } = createFactoryTeammateMcpServer({
skipBootstrap: true,
agentName: 'ar-139-impl-factory',
agentToken: 'at_live_test',
}, { fleet })
const client = new Client({ name: 'factory-teammate-test', version: '1.0.0' })
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
await Promise.all([server.connect(serverTransport), client.connect(clientTransport)])

try {
const listed = await client.listTools()
expect(listed.tools.map((tool) => tool.name)).toEqual(expect.arrayContaining([
'discover_teammates',
'ask_teammate',
// Positive control: Factory extends rather than replaces Agent Relay.
'send_dm',
]))

const discovered = await client.callTool({
name: 'discover_teammates',
arguments: { skill: 'infra-watch' },
})
expect(toolJson(discovered)).toEqual([expect.objectContaining({ name: 'infra-agent' })])

const asked = await client.callTool({
name: 'ask_teammate',
arguments: {
skill: 'infra-watch',
question: 'Why is the deployment stuck?',
timeoutMs: 1_000,
},
})
expect(toolJson(asked)).toMatchObject({
teammate: { name: 'infra-agent' },
reply: {
from: 'infra-agent',
target: 'ar-139-impl-factory',
body: 'The deployment heartbeat is frozen.',
},
})
expect(fleet.messages).toEqual([expect.objectContaining({
from: 'ar-139-impl-factory',
to: 'infra-agent',
text: 'Why is the deployment stuck?',
})])
} finally {
await client.close()
await server.close()
}
})
})

function toolJson(result: { content?: unknown }): unknown {
const content = result.content as Array<{ type: string; text?: string }> | undefined
const text = content?.find((entry) => entry.type === 'text')?.text
if (!text) throw new Error('tool returned no text content')
return JSON.parse(text)
}
125 changes: 125 additions & 0 deletions src/cli/teammate-mcp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import {
createAgentRelayMcpServer,
optionsFromEnv,
resolveStdioBootstrapOptions,
type AgentRelayMcpServerOptions,
} from 'agent-relay/mcp'
import { z } from 'zod'

import type { FleetClient, TeammateAgent } from '../ports/fleet'
import { RelayFleetClient } from '../fleet/relay-fleet-client'
import { askTeammate } from '../fleet/teammates'

export interface FactoryTeammateMcpDependencies {
fleet?: FleetClient
}

/**
* Extend the same Agent Relay MCP server already injected into Factory workers
* with directory-backed, bounded teammate tools. Keeping one server preserves
* every existing Relay tool and identity setting while making #139 reachable
* from the spawned worker rather than only from Factory's library API.
*/
export function createFactoryTeammateMcpServer(
options: AgentRelayMcpServerOptions,
dependencies: FactoryTeammateMcpDependencies = {},
): { server: McpServer; fleet: FleetClient } {
const server = createAgentRelayMcpServer(options)
const fleet = dependencies.fleet ?? new RelayFleetClient({
workspaceKey: options.workspaceKey ?? options.apiKey,
agentToken: options.agentToken,
agentName: options.agentName,
baseUrl: options.baseUrl,
env: {},
registerLifecycleAction: false,
})

server.registerTool('discover_teammates', {
title: 'Discover Teammates',
description: 'Find Relaycast agents by exact skill/tag or free-text card query before asking one for help.',
inputSchema: {
skill: z.string().trim().min(1).optional(),
tag: z.string().trim().min(1).optional(),
q: z.string().trim().min(1).optional(),
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true,
},
}, async ({ skill, tag, q }) => {
if (!skill && !tag && !q) {
throw new Error('discover_teammates requires skill, tag, or q')
}
return jsonToolResult(await fleet.discoverTeammates({ skill, tag, q }))
})

server.registerTool('ask_teammate', {
title: 'Ask a Teammate',
description: 'Discover or address one teammate, send a question over Relay, and wait for its reply within a bounded deadline.',
inputSchema: {
question: z.string().trim().min(1),
to: z.string().trim().min(1).optional(),
skill: z.string().trim().min(1).optional(),
tag: z.string().trim().min(1).optional(),
q: z.string().trim().min(1).optional(),
timeoutMs: z.number().int().positive().max(120_000).optional(),
},
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: true,
},
}, async ({ question, to, skill, tag, q, timeoutMs }) => {
if (!to && !skill && !tag && !q) {
throw new Error('ask_teammate requires to, skill, tag, or q')
}
const from = requiredAgentName(options.agentName)
const result = await askTeammate(fleet, {
from,
question,
...(to ? { teammate: directTeammate(to) } : { skill, tag, q }),
timeoutMs,
})
return jsonToolResult(result)
})

return { server, fleet }
}

/** Start the worker-facing Factory + Agent Relay MCP server on stdio. */
export async function startFactoryTeammateMcpStdio(
options: AgentRelayMcpServerOptions = optionsFromEnv(),
): Promise<void> {
const resolved = await resolveStdioBootstrapOptions(options)
const { server } = createFactoryTeammateMcpServer(resolved)
await server.connect(new StdioServerTransport())
}

function directTeammate(address: string): TeammateAgent {
return {
name: address,
address,
description: 'Direct Relay teammate target supplied by the worker.',
skills: [],
tags: [],
kind: 'native',
url: `relay://${encodeURIComponent(address)}`,
}
}

function requiredAgentName(value: string | undefined): string {
const name = value?.trim()
if (!name) throw new Error('ask_teammate requires the spawned worker identity (RELAY_AGENT_NAME)')
return name
}

function jsonToolResult(value: unknown) {
return {
content: [{ type: 'text' as const, text: JSON.stringify(value, null, 2) }],
}
}
Loading