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
11 changes: 10 additions & 1 deletion kun/src/adapters/tool/delegation-tool-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export function buildDelegationToolProviders(runtime: DelegationRuntime | undefi
additionalProperties: false
},
policy: 'auto',
execute: async (args, context) => {
execute: async (args, context, onUpdate) => {
const prompt = typeof args.prompt === 'string' ? args.prompt.trim() : ''
if (!prompt) return { output: { error: 'prompt is required' }, isError: true }
const record = await runtime.runChild({
Expand All @@ -48,6 +48,15 @@ export function buildDelegationToolProviders(runtime: DelegationRuntime | undefi
...(typeof args.model === 'string' ? { model: args.model } : {}),
...(typeof args.profile === 'string' ? { profile: args.profile } : {}),
...(args.detach === true ? { detach: true } : {}),
// Emit a partial result the moment the child id exists, so the GUI
// can offer "open session" (and stream the child live) while the
// child is still running — not only after it completes.
onStart: (childId, profile) => {
void onUpdate?.({
output: { childId, status: 'running', ...(profile ? { profile } : {}) },
isError: false
})
},
signal: context.abortSignal
})
return {
Expand Down
7 changes: 6 additions & 1 deletion kun/src/delegation/builtin-profiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,5 +114,10 @@ export function mergeBuiltinSubagentProfiles(
const override = config.profiles[id]
profiles[id] = override ? { ...builtin, ...override } : builtin
}
return { ...config, profiles }
// Default a child with no explicit `profile` to the built-in `general`
// profile (always present after the merge). Without this, an omitted profile
// resolves to `undefined`, so the run carries no profile id — the GUI then
// can't label the subagent and falls back to a generic name.
const defaultProfile = config.defaultProfile ?? 'general'
return { ...config, profiles, defaultProfile }
}
71 changes: 57 additions & 14 deletions kun/src/delegation/child-agent-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import type { TokenEconomyConfig } from '../loop/token-economy.js'
import type { MemoryStore } from '../memory/memory-store.js'
import type { ModelClient } from '../ports/model-client.js'
import { RandomIdGenerator } from '../ports/id-generator.js'
import type { SessionStore } from '../ports/session-store.js'
import type { ThreadStore } from '../ports/thread-store.js'
import type { ToolHost } from '../ports/tool-host.js'
import type { SkillRuntime } from '../skills/skill-runtime.js'
import { RuntimeEventRecorder } from '../services/runtime-event-recorder.js'
Expand All @@ -41,14 +43,42 @@ export type ChildAgentExecutorOptions = {
modelCapabilities?: (model: string) => ModelCapabilityMetadata
skillRuntime?: SkillRuntime
memoryStore?: MemoryStore
/**
* Persistence wiring. When the main runtime's stores + event recorder are
* supplied, the child runs as a persisted `relation: 'side'` thread on the
* shared event bus: its full session (reasoning, tool calls, results) is
* queryable via `getThreadDetail(childId)` and streams live to UI
* subscribers. The thread is hidden from the default thread list (the store
* filters `side`). When omitted (e.g. in unit tests) the child falls back to
* throwaway in-memory stores, preserving full isolation.
*/
sessionStore?: SessionStore
threadStore?: ThreadStore
events?: RuntimeEventRecorder
}

export function createChildAgentExecutor(options: ChildAgentExecutorOptions): ChildRunExecutor {
return async (input) => {
const nowIso = options.nowIso ?? (() => new Date().toISOString())
const eventBus = new InMemoryEventBus()
const sessionStore = new InMemorySessionStore()
const threadStore = new InMemoryThreadStore()
// Persist into the main runtime's stores + event bus when supplied, so the
// child session is queryable and streams live; otherwise stay isolated in
// throwaway in-memory stores (preserves test behavior). The recorder is
// shared too — events persist-before-publish to the same bus, and seq
// allocation is per-thread (childId), so child events never bleed into the
// parent thread's stream.
const sessionStore: SessionStore = options.sessionStore ?? new InMemorySessionStore()
const threadStore: ThreadStore = options.threadStore ?? new InMemoryThreadStore()
const events =
options.events ??
(() => {
const eventBus = new InMemoryEventBus()
return new RuntimeEventRecorder({
eventBus,
sessionStore,
allocateSeq: (threadId) => eventBus.allocateSeq(threadId),
nowIso
})
})()
const usage = new UsageService()
const ids = new RandomIdGenerator()
const inflight = new InflightTracker()
Expand All @@ -57,12 +87,6 @@ export function createChildAgentExecutor(options: ChildAgentExecutorOptions): Ch
contextCompaction: options.contextCompaction,
models: options.models
})
const events = new RuntimeEventRecorder({
eventBus,
sessionStore,
allocateSeq: (threadId) => eventBus.allocateSeq(threadId),
nowIso
})
const turns = new TurnService({
threadStore,
sessionStore,
Expand Down Expand Up @@ -142,8 +166,9 @@ export function createChildAgentExecutor(options: ChildAgentExecutorOptions): Ch
})

const model = input.model?.trim() || options.defaultModel
const title = childThreadTitle(input.childId, input.label, input.profile)
const thread = await threads.create({
title: childThreadTitle(input.childId, input.label),
title,
workspace: input.workspace?.trim() || '~',
model,
mode: 'agent',
Expand All @@ -155,7 +180,12 @@ export function createChildAgentExecutor(options: ChildAgentExecutorOptions): Ch
...(input.providerId ? { providerId: input.providerId } : {})
}, {
id: input.childId,
title: childThreadTitle(input.childId, input.label)
title,
// Persist as a side branch of the parent: hidden from the default thread
// list, but loadable on demand so the user can open the subagent's own
// session from the parent's delegate_task card.
relation: 'side',
parentThreadId: input.parentThreadId
})
// A profile preamble rides in the prompt body (not the system prompt) so
// the cached stable prefix stays byte-identical to the main agent's.
Expand All @@ -174,8 +204,21 @@ export function createChildAgentExecutor(options: ChildAgentExecutorOptions): Ch
}
})
const status = await loop.runTurn(thread.id, started.turnId)
// Only a FATAL error fails the child. Recoverable tool errors — a tool
// rejected by the child's read-only policy, or a tool that crashed — are
// recorded as `severity: 'warning'` error events but the loop hands the
// model an error tool-result it adapts to and the turn still completes.
// Treating those as fatal wrongly marked the whole subagent "failed" for a
// single denied `bash` call. Genuine failures are caught by the `status`
// check below; here we only honor non-warning (fatal) error events.
const runtimeError = (await sessionStore.loadEventsSince(thread.id, 0))
.find((event) => event.kind === 'error' && event.turnId === started.turnId)
.find(
(event) =>
event.kind === 'error' &&
event.turnId === started.turnId &&
event.severity !== 'warning' &&
event.severity !== 'info'
)
if (runtimeError?.kind === 'error') {
throw new Error(runtimeError.message)
}
Expand All @@ -199,8 +242,8 @@ export function createChildAgentExecutor(options: ChildAgentExecutorOptions): Ch
}
}

function childThreadTitle(childId: string, label?: string): string {
const suffix = label?.trim() || childId
function childThreadTitle(childId: string, label?: string, profile?: string): string {
const suffix = label?.trim() || profile?.trim() || childId
return `Child agent: ${suffix}`
}

Expand Down
15 changes: 15 additions & 0 deletions kun/src/delegation/delegation-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ export type ChildRunExecutor = (input: {
parentThreadId: string
parentTurnId: string
label?: string
/** Resolved subagent profile id (e.g. `general`, `explore`); used for the child thread title. */
profile?: string
prompt: string
workspace?: string
model?: string
Expand Down Expand Up @@ -179,6 +181,14 @@ export class DelegationRuntime {
* after the parent turn finishes. Default: false (synchronous).
*/
detach?: boolean
/**
* Invoked once, as soon as the child id is allocated (before the child
* finishes), so the caller can surface the id while the child is still
* running — e.g. the delegate_task tool emits a partial result so the GUI
* can offer "open session" mid-run. Carries the resolved profile id so the
* caller can keep showing the subagent type while it runs.
*/
onStart?: (childId: string, profile?: string) => void
signal: AbortSignal
}): Promise<ChildRunRecord> {
const config = this.options.config
Expand Down Expand Up @@ -235,6 +245,9 @@ export class DelegationRuntime {
})
await this.options.store.upsert(record)
await this.recordChildEvent(record)
// Surface the child id immediately (both sync + detached paths) so the
// caller can show it while the child is still running.
input.onStart?.(record.id, profileName)

if (input.detach) {
// Spawn an independent signal so the parent turn's signal aborting
Expand Down Expand Up @@ -295,6 +308,7 @@ export class DelegationRuntime {
parentThreadId: input.parentThreadId,
parentTurnId: input.parentTurnId,
...(input.label ? { label: input.label } : {}),
...(profileName ? { profile: profileName } : {}),
prompt: input.prompt,
workspace: input.workspace,
model: resolvedModel,
Expand Down Expand Up @@ -397,6 +411,7 @@ export class DelegationRuntime {
parentThreadId: args.parentThreadId,
parentTurnId: args.parentTurnId,
...(args.label ? { label: args.label } : {}),
...(args.profileName ? { profile: args.profileName } : {}),
prompt: args.prompt,
workspace: args.workspace,
model: args.resolvedModel,
Expand Down
5 changes: 5 additions & 0 deletions kun/src/server/runtime-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,11 @@ export async function createKunServeRuntime(
modelCapabilities,
skillRuntime,
tokenEconomy,
// Persist the child as a hidden `side` thread on the shared stores +
// event bus so its session is loadable and streams live in the GUI.
sessionStore,
threadStore,
events,
...(options.runtime ? { runtime: options.runtime } : {}),
...(memoryStore ? { memoryStore } : {}),
nowIso
Expand Down
12 changes: 11 additions & 1 deletion kun/src/services/thread-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,15 @@ export class ThreadService {

async create(
request: CreateThreadRequest,
options: { id?: string; title?: string; status?: ThreadStatus } = {}
options: {
id?: string
title?: string
status?: ThreadStatus
/** Relationship to a parent thread; `side` threads are hidden from the default list. */
relation?: ThreadRelation
/** Parent thread this thread branches from (used by `side`/`fork` relations). */
parentThreadId?: string
} = {}
): Promise<ThreadRecord> {
// Always advance the id generator so externally-supplied ids
// don't collide with later allocations from `fork`/etc.
Expand All @@ -129,6 +137,8 @@ export class ThreadService {
approvalPolicy: request.approvalPolicy,
sandboxMode: request.sandboxMode,
...(request.costBudgetUsd !== undefined ? { costBudgetUsd: request.costBudgetUsd } : {}),
...(options.relation ? { relation: options.relation } : {}),
...(options.parentThreadId ? { parentThreadId: options.parentThreadId } : {}),
status: options.status
})
await this.threadStore.upsert(thread)
Expand Down
Loading
Loading