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
89 changes: 75 additions & 14 deletions src/mcp/bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,15 @@
* MCP_CODER_FANOUT_HARNESSES comma-separated harness ids to use for variants > 1
* MCP_DISABLE_CODER set to `1` to omit `delegate_code`
* MCP_DISABLE_RESEARCHER set to `1` to omit `delegate_research` even when peer is present
* MCP_RESEARCHER_HARNESS researcher worker harness (default `opencode`)
* MCP_RESEARCHER_MODEL researcher worker model id (falls back to
* MCP_WORKER_MODEL, then WORKER_MODEL, then a default)
* MCP_RESEARCHER_FANOUT_HARNESSES comma-separated harnesses for researcher variants > 1
* MCP_RESEARCHER_FANOUT_MODELS comma-separated per-harness models, index-aligned
* MCP_RESEARCHER_ROUTER_KEY OpenAI-compatible router key for the in-box agent
* (defaults to TANGLE_API_KEY)
* MCP_RESEARCHER_ROUTER_BASE_URL router base for the in-box agent (defaults to the
* repo's resolveRouterBaseUrl, normalized to `/v1`)
* AGENT_RUNTIME_DELEGATION_STATE_FILE
* optional — absolute path of a JSON state
* file. When set, delegation records persist
Expand Down Expand Up @@ -66,6 +75,7 @@ import {
type ResearcherDelegate,
settleDetachedCoderTurn,
} from './delegates'
import { DEFAULT_SANDBOX_BASE_URL } from './delegation-profile'
import { FileDelegationStore } from './delegation-store'
import { composeLoopTraceEmitters } from './delegation-trace'
import {
Expand All @@ -78,6 +88,11 @@ import {
runDetachedTurn,
} from './detached-turn'
import type { DelegationExecutor } from './executor'
import {
applyRouterEnv,
type ProvisionableSpec,
resolveResearcherProvisioning,
} from './researcher-provisioning'
import { createMcpServer } from './server'
import { type DelegationResumeDriver, DelegationTaskQueue } from './task-queue'
import {
Expand Down Expand Up @@ -352,11 +367,11 @@ async function loadSandboxClient(apiKey: string | undefined): Promise<SandboxCli
)
process.exit(2)
}
const baseUrl = process.env.SANDBOX_BASE_URL
return new SandboxCtor({
apiKey,
...(baseUrl ? { baseUrl } : {}),
})
// @tangle-network/sandbox ≥0.6 makes baseUrl required; default it so the MCP server
// starts without forcing every caller to set SANDBOX_BASE_URL. Treat empty/whitespace as
// unset (|| not ??) so `SANDBOX_BASE_URL=` still resolves to the default.
const baseUrl = process.env.SANDBOX_BASE_URL?.trim() || DEFAULT_SANDBOX_BASE_URL
return new SandboxCtor({ apiKey, baseUrl })
}

interface ResearcherProfilePreset {
Expand Down Expand Up @@ -391,21 +406,50 @@ async function loadResearcherSupport(
const profilesSpecifier = '@tangle-network/agent-knowledge/profiles'
const mod = await import(profilesSpecifier).catch(() => undefined)
if (!mod) return undefined
type SingleFactory = (opts: { task: unknown }) => ResearcherProfilePreset
type FanoutFactory = (opts: { task: unknown }) => ResearcherFanoutPreset
type SingleFactory = (opts: {
task: unknown
harness?: string
model?: string
}) => ResearcherProfilePreset
type FanoutFactory = (opts: {
task: unknown
harnesses?: string[]
models?: (string | undefined)[]
}) => ResearcherFanoutPreset
const fanoutFactory = (mod as { multiHarnessResearcherFanout?: FanoutFactory })
.multiHarnessResearcherFanout
const singleFactory = (mod as { researcherProfile?: SingleFactory }).researcherProfile
if (!fanoutFactory || !singleFactory) return undefined

// Worker harness + model + provider auth. Two reasons a researcher run otherwise makes
// zero LLM calls and "produces no winner" on a successful box: (1) the profile's default
// harness (opencode/zai-coding-plan/glm-5.1) is not broadly provisionable; (2) the
// sandbox SDK does not wire backend.model.apiKey into the in-box agent's OpenAI-compatible
// provider. resolveResearcherProvisioning picks a provisionable harness + model and the
// router creds (all env-overridable); applyRouterEnv injects them as box env. Applied to
// BOTH the single-variant path and every fanout agent-run so variants > 1 work too.
const {
harness,
model,
routerKey,
routerBaseUrl,
fanoutHarnesses: cfgFanoutHarnesses,
fanoutModels,
} = resolveResearcherProvisioning()
const buildPreset = (task: unknown): ResearcherProfilePreset => {
const preset = singleFactory({ task, harness, model })
applyRouterEnv(preset.agentRunSpec as ProvisionableSpec, routerKey, routerBaseUrl)
return preset
}

const settleSingle = async (
turn: DetachedTurn,
args: DelegateResearchArgs,
sessionId: string,
signal: AbortSignal,
): Promise<ResearchOutputShape> => {
const task = buildResearchTask(args)
const preset = singleFactory({ task })
const preset = buildPreset(task)
if (!preset.validator) {
throw new Error('agent-runtime-mcp: researcher preset exposes no validator; cannot settle')
}
Expand All @@ -423,7 +467,7 @@ async function loadResearcherSupport(
const loopEmitter = composeLoopTraceEmitters(traceEmitter, ctx.traceEmitter)
ctx.report({ iteration: 0, phase: 'starting' })
if (variants <= 1) {
const preset = singleFactory({ task })
const preset = buildPreset(task)
// Detached dispatch — same contract as the coder delegate: one session
// on one box, driveTurn ticks, resume key bound to the sandbox id.
if (ctx.detachedSessionRef !== undefined && ctx.updateDetachedSessionRef) {
Expand Down Expand Up @@ -472,10 +516,26 @@ async function loadResearcherSupport(
ctx.report({ iteration: 1, phase: 'completed' })
return output as ResearchOutputShape
}
const fanout = fanoutFactory({ task })
// Match the single-variant fix: use a provisionable harness/model and inject router
// creds into every fanout agent-run, else variants > 1 makes zero LLM calls. Default to
// `variants` copies of the working harness; MCP_RESEARCHER_FANOUT_HARNESSES overrides for
// diversity (with optional per-harness MCP_RESEARCHER_FANOUT_MODELS).
const fanoutHarnesses = cfgFanoutHarnesses ?? Array.from({ length: variants }, () => harness)
const fanout = fanoutFactory({
task,
harnesses: fanoutHarnesses,
models: fanoutHarnesses.map((_, i) => fanoutModels?.[i] ?? model),
})
for (const spec of fanout.agentRuns) {
applyRouterEnv(spec as ProvisionableSpec, routerKey, routerBaseUrl)
}
// The harness list may be shorter than `variants` (misconfig) — never claim more
// iterations than there are runs.
const runs = fanout.agentRuns.slice(0, variants)
const effectiveVariants = Math.max(1, runs.length)
const result = await runLoop({
driver: fanout.driver,
agentRuns: fanout.agentRuns.slice(0, variants),
agentRuns: runs,
output: fanout.output,
validator: fanout.validator,
task,
Expand All @@ -484,8 +544,8 @@ async function loadResearcherSupport(
signal: ctx.signal,
...(loopEmitter ? { traceEmitter: loopEmitter } : {}),
},
maxIterations: variants,
maxConcurrency: Math.min(maxConcurrency, variants),
maxIterations: effectiveVariants,
maxConcurrency: Math.min(maxConcurrency, effectiveVariants),
})
const output = result.winner?.output
if (!output) throw new Error('researcher delegate fanout produced no winner')
Expand All @@ -498,7 +558,8 @@ async function loadResearcherSupport(
resume: {
message(args) {
const task = buildResearchTask(args)
const spec = singleFactory({ task }).agentRunSpec as AgentRunSpec<unknown>
// Use the same preset construction as dispatch so the displayed prompt can't drift.
const spec = buildPreset(task).agentRunSpec as AgentRunSpec<unknown>
return spec.taskToPrompt(task)
},
async settle(turn, args, signal) {
Expand Down
2 changes: 1 addition & 1 deletion src/mcp/delegation-profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ const OTEL_FORWARD_KEYS = [
'PARENT_SPAN_ID',
] as const

const DEFAULT_SANDBOX_BASE_URL = 'https://sandbox.tangle.tools'
export const DEFAULT_SANDBOX_BASE_URL = 'https://sandbox.tangle.tools'

export interface BuildDelegationMcpServerOptions {
/** Sandbox API key forwarded as `TANGLE_API_KEY` to the MCP child. The
Expand Down
100 changes: 100 additions & 0 deletions src/mcp/researcher-provisioning.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/**
* Researcher delegate provisioning — resolves the worker harness, model, and router
* credentials for `delegate_research`, and injects the OpenAI-compatible router creds
* into a sandbox agent-run spec.
*
* Why this exists: the agent-knowledge researcher profile defaults to a harness
* (`opencode/zai-coding-plan/glm-5.1`) that isn't broadly provisionable, and the sandbox
* SDK does not wire `backend.model.apiKey` into the in-box agent's OpenAI-compatible
* provider. So the MCP server picks a provisionable harness + model and passes the router
* creds as box env. Everything is env-overridable and reuses the repo's router resolution.
*/
import { type RouterEnv, resolveRouterBaseUrl } from '../model-resolution.js'

export interface ResearcherProvisioning {
harness: string
/** Worker model id (router-served). */
model: string
/** OpenAI-compatible router key for the in-box provider; undefined disables injection. */
routerKey?: string
/** OpenAI-compatible router base, always ending in a `/vN` segment. */
routerBaseUrl: string
/** Explicit fanout harness list (MCP_RESEARCHER_FANOUT_HARNESSES); undefined ⇒ caller defaults. */
fanoutHarnesses?: string[]
/** Per-harness fanout model overrides (MCP_RESEARCHER_FANOUT_MODELS), index-aligned. */
fanoutModels?: string[]
}

/** A sandbox agent-run spec whose box env can be overridden. */
export interface ProvisionableSpec {
sandboxOverrides?: { env?: Record<string, string> } & Record<string, unknown>
}

const DEFAULT_HARNESS = 'opencode'
const DEFAULT_MODEL = 'moonshotai/kimi-k2.6'

function trimmed(value: string | undefined): string | undefined {
const t = value?.trim()
return t ? t : undefined
}

function csv(value: string | undefined): string[] | undefined {
const list = value
?.split(',')
.map((v) => v.trim())
.filter(Boolean)
return list && list.length > 0 ? list : undefined
}

/**
* Resolve harness/model/router from env. Model falls back through the repo's
* `WORKER_MODEL` convention; router base reuses `resolveRouterBaseUrl` (TANGLE_ROUTER_URL
* / TANGLE_ROUTER_BASE_URL) and is normalized to an OpenAI-compatible `/v1` endpoint.
*/
export function resolveResearcherProvisioning(
env: NodeJS.ProcessEnv = process.env,
): ResearcherProvisioning {
const harness = trimmed(env.MCP_RESEARCHER_HARNESS) ?? DEFAULT_HARNESS
const model =
trimmed(env.MCP_RESEARCHER_MODEL) ??
trimmed(env.MCP_WORKER_MODEL) ??
trimmed(env.WORKER_MODEL) ??
DEFAULT_MODEL
const routerKey = trimmed(env.MCP_RESEARCHER_ROUTER_KEY) ?? trimmed(env.TANGLE_API_KEY)
const base = trimmed(env.MCP_RESEARCHER_ROUTER_BASE_URL) ?? resolveRouterBaseUrl(env as RouterEnv)
const routerBaseUrl = /\/v\d+\/?$/.test(base)
? base.replace(/\/$/, '')
: `${base.replace(/\/$/, '')}/v1`
const fanoutHarnesses = csv(env.MCP_RESEARCHER_FANOUT_HARNESSES)
const fanoutModels = csv(env.MCP_RESEARCHER_FANOUT_MODELS)
return {
harness,
model,
...(routerKey ? { routerKey } : {}),
routerBaseUrl,
...(fanoutHarnesses ? { fanoutHarnesses } : {}),
...(fanoutModels ? { fanoutModels } : {}),
}
}

/**
* Overlay the router creds onto a spec's box env (in place): preserve every env var the
* preset already supplied and set OPENAI_API_KEY / OPENAI_BASE_URL on top (these two are
* intentionally authoritative — they point the in-box provider at the router). No-op when
* there is no router key.
*/
export function applyRouterEnv(
spec: ProvisionableSpec,
routerKey: string | undefined,
routerBaseUrl: string,
): void {
if (!routerKey) return
spec.sandboxOverrides = {
...(spec.sandboxOverrides ?? {}),
env: {
...(spec.sandboxOverrides?.env ?? {}),
OPENAI_API_KEY: routerKey,
OPENAI_BASE_URL: routerBaseUrl,
},
}
}
Loading
Loading