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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@tangle-network/agent-runtime",
"version": "0.40.0",
"version": "0.41.0",
"description": "Reusable runtime lifecycle for domain-specific agents.",
"homepage": "https://github.com/tangle-network/agent-runtime#readme",
"repository": {
Expand Down
37 changes: 26 additions & 11 deletions src/mcp/bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,13 @@
* MCP_DISABLE_RESEARCHER set to `1` to omit `delegate_research` even when peer is present
*/

import type { LoopSandboxClient } from '../loops'
import type { LoopSandboxClient, LoopTraceEmitter } from '../loops'
import { runLoop } from '../loops'
import { detectExecutor } from './bin-helpers'
import { createDefaultCoderDelegate, type ResearcherDelegate } from './delegates'
import type { DelegationExecutor } from './executor'
import { createMcpServer } from './server'
import { createPropagatingTraceEmitter, readTraceContextFromEnv } from './trace-propagation'
import type { ResearchOutputShape } from './types'

async function main(): Promise<void> {
Expand Down Expand Up @@ -76,30 +77,43 @@ async function main(): Promise<void> {
process.stderr.write(`agent-runtime-mcp: delegation placement → ${executor.describe()}\n`)
}

// Export delegated-loop topology spans to the OTLP / Tangle Intelligence sink
// when OTEL_EXPORTER_OTLP_ENDPOINT is set (+ TRACE_ID / PARENT_SPAN_ID for
// correlation with the caller's trace). A cheap no-op when the endpoint is
// unset — the fleet forwards the env into this MCP's process to turn it on.
const { emitter: traceEmitter, exporter: traceExporter } = createPropagatingTraceEmitter(
readTraceContextFromEnv(),
)
if (process.env.OTEL_EXPORTER_OTLP_ENDPOINT) {
process.stderr.write(
`agent-runtime-mcp: exporting loop topology → ${process.env.OTEL_EXPORTER_OTLP_ENDPOINT}\n`,
)
}

const coderDelegate =
wantCoder && executor
? createDefaultCoderDelegate({
executor,
fanoutHarnesses,
maxConcurrency,
traceEmitter,
})
: undefined

const researcherDelegate =
wantResearcher && executor
? await loadResearcherDelegate(executor.client, maxConcurrency)
? await loadResearcherDelegate(executor.client, maxConcurrency, traceEmitter)
: undefined

const server = createMcpServer({ coderDelegate, researcherDelegate })

process.on('SIGINT', () => {
const shutdown = () => {
server.stop()
process.exit(0)
})
process.on('SIGTERM', () => {
server.stop()
process.exit(0)
})
void traceExporter?.shutdown().finally(() => process.exit(0))
if (!traceExporter) process.exit(0)
}
process.on('SIGINT', shutdown)
process.on('SIGTERM', shutdown)

await server.serve()
}
Expand Down Expand Up @@ -157,6 +171,7 @@ interface ResearcherFanoutPreset {
async function loadResearcherDelegate(
sandboxClient: LoopSandboxClient,
maxConcurrency: number,
traceEmitter?: LoopTraceEmitter,
): Promise<ResearcherDelegate | undefined> {
// Optional peer — when `@tangle-network/agent-knowledge` isn't installed,
// we silently omit the researcher tool from the advertisement. The
Expand Down Expand Up @@ -210,7 +225,7 @@ async function loadResearcherDelegate(
output: preset.output,
validator: preset.validator,
task,
ctx: { sandboxClient, signal: ctx.signal },
ctx: { sandboxClient, signal: ctx.signal, ...(traceEmitter ? { traceEmitter } : {}) },
maxIterations: 1,
maxConcurrency,
})
Expand All @@ -226,7 +241,7 @@ async function loadResearcherDelegate(
output: fanout.output,
validator: fanout.validator,
task,
ctx: { sandboxClient, signal: ctx.signal },
ctx: { sandboxClient, signal: ctx.signal, ...(traceEmitter ? { traceEmitter } : {}) },
maxIterations: variants,
maxConcurrency: Math.min(maxConcurrency, variants),
})
Expand Down
15 changes: 12 additions & 3 deletions src/mcp/delegates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
* pass `researcherDelegate` explicitly when constructing the server.
*/

import type { Iteration, LoopSandboxClient } from '../loops'
import type { Iteration, LoopSandboxClient, LoopTraceEmitter } from '../loops'
import { runLoop } from '../loops'
import { type CoderOutput, coderProfile, multiHarnessCoderFanout } from '../profiles/coder'
import { createSiblingSandboxExecutor, type DelegationExecutor } from './executor'
Expand Down Expand Up @@ -110,6 +110,14 @@ export interface CreateDefaultCoderDelegateOptions {
reviewer?: CoderReviewer
/** Winner-selection strategy among eligible candidates. Default `highest-score`. */
winnerSelection?: CoderWinnerSelection
/**
* Loop trace emitter forwarded into every delegated `runLoop`. Wire
* `createPropagatingTraceEmitter(readTraceContextFromEnv())` here (the bin
* does) so delegated build-loops export their topology spans to the OTLP /
* Tangle Intelligence sink when `OTEL_EXPORTER_OTLP_ENDPOINT` is set — and
* are a cheap no-op when it isn't. Configurable by construction.
*/
traceEmitter?: LoopTraceEmitter
}

/**
Expand All @@ -126,6 +134,7 @@ export function createDefaultCoderDelegate(
const sandboxClient = executor.client
const fanoutHarnesses = options.fanoutHarnesses
const maxConcurrency = options.maxConcurrency ?? 4
const traceEmitter = options.traceEmitter
return async (args, ctx) => {
const task: CoderTask = {
goal: buildCoderGoal(args),
Expand All @@ -145,7 +154,7 @@ export function createDefaultCoderDelegate(
output,
validator,
task,
ctx: { sandboxClient, signal: ctx.signal },
ctx: { sandboxClient, signal: ctx.signal, ...(traceEmitter ? { traceEmitter } : {}) },
maxIterations: 1,
maxConcurrency,
})
Expand All @@ -172,7 +181,7 @@ export function createDefaultCoderDelegate(
output: fanout.output,
validator: fanout.validator,
task,
ctx: { sandboxClient, signal: ctx.signal },
ctx: { sandboxClient, signal: ctx.signal, ...(traceEmitter ? { traceEmitter } : {}) },
maxIterations: variants,
maxConcurrency: Math.min(maxConcurrency, variants),
})
Expand Down
20 changes: 20 additions & 0 deletions tests/mcp/coder-delegate-selection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,3 +108,23 @@ describe('createDefaultCoderDelegate — reviewer gate + winner selection', () =
expect(['small', 'big']).toContain(out.branch)
})
})

import type { LoopTraceEmitter, LoopTraceEvent } from '../../src/loops'

describe('createDefaultCoderDelegate — trace emitter wiring (MCP → OTEL sink)', () => {
it('forwards the trace emitter into the delegated runLoop (loop.* spans emitted)', async () => {
const events: LoopTraceEvent[] = []
const traceEmitter: LoopTraceEmitter = { emit: (e) => void events.push(e) }
const delegate = createDefaultCoderDelegate({
sandboxClient: candidateClient(),
fanoutHarnesses: ['claude-code', 'codex'],
traceEmitter,
})
await delegate(args, ctx)
const kinds = new Set(events.map((e) => e.kind))
// the delegated loop's topology spans reach the emitter → the OTLP exporter
expect(kinds.has('loop.started')).toBe(true)
expect(kinds.has('loop.ended')).toBe(true)
expect(kinds.has('loop.iteration.ended')).toBe(true)
})
})
Loading