diff --git a/package.json b/package.json index 45cf9ee..2b5db33 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/mcp/bin.ts b/src/mcp/bin.ts index 204ad9e..ae6d259 100644 --- a/src/mcp/bin.ts +++ b/src/mcp/bin.ts @@ -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 { @@ -76,30 +77,43 @@ async function main(): Promise { 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() } @@ -157,6 +171,7 @@ interface ResearcherFanoutPreset { async function loadResearcherDelegate( sandboxClient: LoopSandboxClient, maxConcurrency: number, + traceEmitter?: LoopTraceEmitter, ): Promise { // Optional peer — when `@tangle-network/agent-knowledge` isn't installed, // we silently omit the researcher tool from the advertisement. The @@ -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, }) @@ -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), }) diff --git a/src/mcp/delegates.ts b/src/mcp/delegates.ts index 41375a9..7c89ccb 100644 --- a/src/mcp/delegates.ts +++ b/src/mcp/delegates.ts @@ -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' @@ -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 } /** @@ -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), @@ -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, }) @@ -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), }) diff --git a/tests/mcp/coder-delegate-selection.test.ts b/tests/mcp/coder-delegate-selection.test.ts index 92da502..1e3e874 100644 --- a/tests/mcp/coder-delegate-selection.test.ts +++ b/tests/mcp/coder-delegate-selection.test.ts @@ -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) + }) +})