Skip to content
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": "Reads the validated Factory feature catalog from a scoped repository clone, then cycles through every @agent-relay/factory feature hourly. Each provider-confirmed, idempotent Slack check names the exact source surface, named end-to-end procedure, verification tier, and supported safety/topology boundary; exact revisioned progress persists across the exhaustive manifest and resets only after a complete cycle.",
"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
19 changes: 19 additions & 0 deletions package-lock.json

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

6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,13 +80,15 @@
"factory-build": "factory-build/run-factory-build.sh"
},
"dependencies": {
"@agentworkforce/delivery": "^4.1.23",
"@agentworkforce/runtime": "^4.1.23",
"@agent-relay/cloud": "^10.6.4",
"@agent-relay/fleet": "^10.6.4",
"@agent-relay/harness-driver": "^10.6.4",
"@agent-relay/integration-prompts": "^10.6.4",
"@agent-relay/sdk": "^10.6.4",
"@agentworkforce/delivery": "^4.1.23",
"@agentworkforce/persona-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 Down
25 changes: 24 additions & 1 deletion src/fleet/internal-fleet-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,12 @@ import { dirname, join } from 'node:path'
import type { BrokerEvent, ListAgent, SendMessageInput, SpawnPtyInput } from '@agent-relay/harness-driver'

import type { PreviewConfig } from '../config/schema'
import type { AgentMessage, AgentPidResolution, Capability, FleetClient, FleetTrackedAgent, PreviewReference, PreviewStartInput, PreviewSweepInput, PreviewSweepResult, RosterEntry, SendInput, SpawnInput, SpawnResult } from '../ports/fleet'
import type { AgentMessage, AgentPidResolution, Capability, FleetClient, FleetTrackedAgent, PreviewReference, PreviewStartInput, PreviewSweepInput, PreviewSweepResult, RosterEntry, SendInput, SpawnInput, SpawnResult, TeammateAgent, TeammateQuery } from '../ports/fleet'
import type { Logger } from '../ports/system'
import { normalizeLogger } from '../logging'
import { TailscalePreviewManager, type PreviewManager } from '../node/tailscale-preview'
import { resolveRelayWorkspaceKey } from './relay-workspace-key'
import { RelaycastTeammateDirectory, type TeammateDirectory } from './teammates'

const requireForResolve = createRequire(import.meta.url)

Expand Down Expand Up @@ -51,6 +52,11 @@ export interface InternalFleetClientOptions {
cwd?: string
connectionPath?: string
workspaceKey?: string
/** Card-aware directory seam; useful with a local broker plus mock directory. */
teammateDirectory?: TeammateDirectory
directoryBaseUrl?: string
directoryFetch?: typeof globalThis.fetch
directoryTimeoutMs?: number
/** Canonical cloud liveness lookup. Injected by tests; derived from workspaceKey in production. */
listCanonicalOnlineAgentNames?: () => Promise<readonly string[]>
/** Local process-liveness probe used to preserve workers that intentionally run without Relay MCP presence. */
Expand Down Expand Up @@ -110,6 +116,7 @@ export class InternalFleetClient implements FleetClient {
readonly #cwd?: string
readonly #connectionPath?: string
readonly #workspaceKey?: string
readonly #teammateDirectory?: TeammateDirectory
readonly #listCanonicalOnlineAgentNames?: () => Promise<readonly string[]>
readonly #isProcessAlive: (pid: number) => boolean
readonly #now: () => number
Expand Down Expand Up @@ -148,6 +155,15 @@ export class InternalFleetClient implements FleetClient {
this.#cwd = options.cwd
this.#connectionPath = options.connectionPath
this.#workspaceKey = options.workspaceKey
const directoryToken = resolveRelayWorkspaceKey({ workspaceKey: options.workspaceKey })
this.#teammateDirectory = options.teammateDirectory ?? (directoryToken
? new RelaycastTeammateDirectory({
baseUrl: options.directoryBaseUrl ?? process.env.RELAY_BASE_URL,
token: directoryToken,
fetch: options.directoryFetch,
timeoutMs: options.directoryTimeoutMs,
})
: undefined)
if (options.listCanonicalOnlineAgentNames) {
this.#listCanonicalOnlineAgentNames = options.listCanonicalOnlineAgentNames
} else if (options.workspaceKey) {
Expand Down Expand Up @@ -359,6 +375,13 @@ export class InternalFleetClient implements FleetClient {
}
}

async discoverTeammates(query: TeammateQuery): Promise<TeammateAgent[]> {
if (!this.#teammateDirectory) {
throw new Error('InternalFleetClient teammate discovery requires a directory or Relay workspace key')
}
return await this.#teammateDirectory.discover(query)
}

async createPreview(input: PreviewStartInput): Promise<PreviewReference> {
assertSelfNode(input.node)
if (!this.#previewManager) throw new Error('Tailscale preview provider is not configured')
Expand Down
34 changes: 33 additions & 1 deletion src/fleet/relay-fleet-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import { AgentRelay } from '@agent-relay/sdk'

import { resolveRelayAgentToken, resolveRelayWorkspaceKey } from './relay-workspace-key'

import type { AgentLifecycleSignal, AgentMessage, Capability, FleetClient, NodeCapability, PreviewReference, PreviewStartInput, PreviewSweepInput, PreviewSweepResult, RosterEntry, SendInput, SpawnInput, SpawnResult } from '../ports/fleet'
import type { AgentLifecycleSignal, AgentMessage, Capability, FleetClient, NodeCapability, PreviewReference, PreviewStartInput, PreviewSweepInput, PreviewSweepResult, RosterEntry, SendInput, SpawnInput, SpawnResult, TeammateAgent, TeammateQuery } from '../ports/fleet'
import { RelaycastTeammateDirectory, type TeammateDirectory } from './teammates'
import type {
RelayActionInvocation,
RelayActionInvocationAck,
Expand Down Expand Up @@ -45,6 +46,10 @@ export interface RelayFleetClientOptions {
lifecycleActionName?: string
/** Engine base URL override. Absent means the SDK default (cast.agentrelay.com). */
baseUrl?: string
/** Card-aware directory seam. Defaults to Relaycast GET /v1/a2a/directory. */
teammateDirectory?: TeammateDirectory
directoryFetch?: typeof globalThis.fetch
directoryTimeoutMs?: number
/** Timeout for a spawn/release invocation to reach a terminal ack status. */
spawnAckTimeoutMs?: number
pollIntervalMs?: number
Expand Down Expand Up @@ -106,6 +111,7 @@ export class RelayFleetClient implements FleetClient {
// therefore createFleet({ backend: 'relay' })) never throws merely because no
// token is configured.
#messaging: RelayMessaging | undefined
#teammateDirectory: TeammateDirectory | undefined
#messagingReady: Promise<RelayMessaging> | undefined
#lifecycleActionReady: Promise<void> | undefined
#authenticatedAgentName: string
Expand All @@ -129,6 +135,7 @@ export class RelayFleetClient implements FleetClient {
this.#sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)))
this.#log = options.log ?? (() => {})
this.#messaging = options.messaging
this.#teammateDirectory = options.teammateDirectory
}

/** Agents spawned through this client that have not exited or been released. */
Expand Down Expand Up @@ -343,6 +350,11 @@ export class RelayFleetClient implements FleetClient {
}
}

async discoverTeammates(query: TeammateQuery): Promise<TeammateAgent[]> {
this.#teammateDirectory ??= this.#createTeammateDirectory()
return await this.#teammateDirectory.discover(query)
}

// `from`/`data` are not representable on the agent-scoped messaging surface:
// every send is authored by the factory's own agent identity.
async sendMessage(input: SendInput): Promise<void> {
Expand Down Expand Up @@ -437,6 +449,26 @@ export class RelayFleetClient implements FleetClient {
}
}

#createTeammateDirectory(): TeammateDirectory {
const env = this.#options.env
const token = resolveRelayWorkspaceKey({
workspaceKey: this.#options.workspaceKey,
...(env ? { env, activeWorkspaceKey: () => undefined } : {}),
}) ?? resolveRelayAgentToken({
agentToken: this.#options.agentToken,
...(env ? { env } : {}),
})
if (!token) {
throw new Error('RelayFleetClient teammate discovery requires a workspace key or agent token')
}
return new RelaycastTeammateDirectory({
baseUrl: this.#options.baseUrl,
token,
fetch: this.#options.directoryFetch,
timeoutMs: this.#options.directoryTimeoutMs,
})
}

#ensureMessaging(): Promise<RelayMessaging> {
if (this.#messaging) return Promise.resolve(this.#messaging)
this.#messagingReady ??= this.#bootstrapMessaging().catch((error) => {
Expand Down
130 changes: 130 additions & 0 deletions src/fleet/teammates.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { describe, expect, it, vi } from 'vitest'

import { FakeFleetClient } from '../testing/fakes'
import { askTeammate, RelaycastTeammateDirectory } from './teammates'

const directoryRows = [{
name: 'infra-agent',
address: 'infra-agent',
skills: [{ id: 'infra-watch', name: 'Infra Watch', tags: ['operations'] }],
tags: ['on-call'],
url: 'https://relay.example/a2a/rpc',
kind: 'native',
status: 'online',
}, {
name: 'review-agent',
address: 'review-agent',
skills: [{ id: 'code-review', name: 'Code Review' }],
tags: ['quality'],
url: 'https://review.example/a2a/rpc',
kind: 'a2a',
status: 'active',
}]

describe('RelaycastTeammateDirectory', () => {
it('sends skill/tag filters and rejects unfiltered server rows client-side', async () => {
const fetch = vi.fn<typeof globalThis.fetch>(async (input) => {
const url = new URL(String(input))
return new Response(JSON.stringify({
ok: true,
// Relaycast may match a registered card alias that is not repeated in
// the returned row. Preserve that server match for free-text queries.
data: url.searchParams.has('q') ? [directoryRows[1]] : directoryRows,
}), { status: 200 })
})
const directory = new RelaycastTeammateDirectory({
baseUrl: 'https://relay.example/',
token: 'rk_live_test',
fetch,
})

await expect(directory.discover({ skill: 'infra-watch' })).resolves.toEqual([
expect.objectContaining({ name: 'infra-agent', address: 'infra-agent', kind: 'native' }),
])
await expect(directory.discover({ skill: 'missing' })).resolves.toEqual([])
await expect(directory.discover({ tag: 'quality' })).resolves.toEqual([
expect.objectContaining({ name: 'review-agent', kind: 'a2a' }),
])
await expect(directory.discover({ q: 'private-card-alias' })).resolves.toEqual([
expect.objectContaining({ name: 'review-agent' }),
])

const firstUrl = fetch.mock.calls[0]?.[0]
expect(String(firstUrl)).toBe('https://relay.example/v1/a2a/directory?skill=infra-watch')
expect(fetch.mock.calls[0]?.[1]).toMatchObject({
headers: expect.objectContaining({ authorization: 'Bearer rk_live_test' }),
})
expect(String(fetch.mock.calls[3]?.[0])).toBe('https://relay.example/v1/a2a/directory?q=private-card-alias')
})
})

describe('askTeammate', () => {
it('discovers a target, sends the question, and resolves only its reply', async () => {
const fleet = new FakeFleetClient()
fleet.teammates.push({
name: 'infra-agent',
address: 'infra-agent',
skills: [{ id: 'infra-watch', name: 'Infra Watch' }],
tags: [],
url: 'https://relay.example/a2a/rpc',
kind: 'native',
})

const asked = askTeammate(fleet, {
from: 'factory-worker',
question: 'Is the deploy healthy?',
skill: 'infra-watch',
timeoutMs: 1_000,
})
await vi.waitFor(() => expect(fleet.messages).toHaveLength(1))
fleet.emitAgentMessage({ from: 'someone-else', target: 'factory-worker', body: 'wrong' })
fleet.emitAgentMessage({ from: 'infra-agent', target: 'different-worker', body: 'also wrong' })
fleet.emitAgentMessage({ from: 'infra-agent', target: 'factory-worker', body: 'All systems green.' })

await expect(asked).resolves.toMatchObject({
teammate: { name: 'infra-agent' },
reply: { from: 'infra-agent', body: 'All systems green.' },
})
expect(fleet.messages[0]).toMatchObject({
to: 'infra-agent',
from: 'factory-worker',
text: 'Is the deploy healthy?',
mode: 'wait',
data: { factoryCapability: 'ask-a-teammate', requester: 'factory-worker' },
})
})

it('returns a bounded timeout when the teammate never replies', async () => {
vi.useFakeTimers()
try {
const fleet = new FakeFleetClient()
fleet.teammates.push({
name: 'silent-agent',
address: 'silent-agent',
skills: [{ id: 'infra-watch', name: 'Infra Watch' }],
tags: [],
url: 'https://relay.example/a2a/rpc',
kind: 'native',
})
const asked = askTeammate(fleet, {
from: 'factory-worker',
question: 'Hello?',
skill: 'infra-watch',
timeoutMs: 50,
})
const rejected = expect(asked).rejects.toThrow('Timed out waiting for a reply from a teammate after 50ms')
await vi.advanceTimersByTimeAsync(50)
await rejected
} finally {
vi.useRealTimers()
}
})

it('refuses to choose an arbitrary teammate without a target or discovery query', async () => {
const fleet = new FakeFleetClient()
await expect(askTeammate(fleet, {
from: 'factory-worker',
question: 'Whoever is first, please answer.',
})).rejects.toThrow('requires a discovered teammate or a skill/tag/query')
})
})
Loading