---
title: Calling in a subcontractor: subagents as just another Tool
number: 8
date: 2026-10-19
author: Shukri Shukriev
medium: https://pub.towardsai.net/sophi-harness-calling-in-a-subcontractor-subagents-as-just-another-tool-ad6a8cd28771
description: A general contractor who needs an electrician doesn’t invent a new business process to get one. They make a phone call, describe the job, hand over…
---

### **Series: Building SoPhi — a Kotlin Agent Harness — Article 08**

![Illustration for Calling in a subcontractor: subagents as just another Tool](image-1.webp)

A general contractor who needs an electrician doesn’t invent a new business process to get one. They make a phone call, describe the job, hand over keys to the rooms that need work and nothing else, and wait for the electrician to report back “done” or “there’s a problem.” No new paperwork system, no new category of contract — hiring a subcontractor uses the exact same mechanism the GC already uses for every other line item on the job: describe the work, scope the access, get a result back.

That’s the whole story of how SoPhi got subagents. The obvious way to add “delegate to a specialist” to an agent harness is to add delegation to the agent loop itself — a new SubagentSpec type, a new branch in the dispatch logic, a new return type to plumb through every caller. That sketch got started and abandoned almost immediately, because it AgentLoop.turn() already does the one thing a subagent needs to do: take an input, do arbitrarily much tool-calling work, and return when it's done. [ADR-007](https://github.com/shukriev/SoPhi/blob/main/doc/adr/ADR-007-subagent-delegation.md), dated 2026-07-04, wrote that down in its title: delegation via a nestedAgentLoop, not a new core primitive. Three days later, PR #2 shipped it, and the entire feature was one new Tool. Nothing about AgentLoop, the prompt builder, or the provider layer changed. The interesting design work moved somewhere else entirely — not "how do we run a subagent," but "how do we stop a subcontractor from getting keys to the whole building, or from subcontracting the job to someone else forever."

## A subagent is just a tool call that takes a while

SubagentTool.execute() is the entire mechanism — build a second AgentLoop, call turn() once, hand back whatever it produced:

```kotlin
val nestedLoop = AgentLoop(
    provider, scopedRegistry, sessionManager,
    confirmationPolicy = confirmationPolicy,
    grants = scopedRegistry.safeGrantsFrom(args.expectedTools),
    contextWindowTokens = contextWindowTokens
)
val subSession = sessionManager.create(title = "subagent:${definition.name}", parentSessionId = parentSessionId)
val config = AgentConfig(model = definition.model ?: parentConfig.model, systemPrompt = definition.systemPrompt)
```

```kotlin
return try {
    val result = nestedLoop.turn(subSession, args.prompt, config)
    result.tip?.content ?: "Error: subagent produced no output"
} finally {
    sessionManager.save(subSession)
}
```

That’s the phone call. What’s worth sitting with is everything this *doesn’t* need: AgentLoop has no idea it's being nested inside another AgentLoop's tool dispatch. It just runs a turn, the same way it always does, for a caller who happens to be an LLM deciding to delegate instead of a human typing at a terminal. The subcontractor doesn't know or care that the GC who hired them is themselves working for someone else.

![The outer AgentLoop dispatches delegate_to_subagent like any other tool call. SubagentTool builds a second loop with a filtered registry and its own child session, runs one turn, and returns the last message as the tool result. The inner loop never learns it is nested.](image-2.webp "The outer AgentLoop dispatches delegate_to_subagent like any other tool call. SubagentTool builds a second loop with a filtered registry and its own child session, runs one turn, and returns the last message as the tool result. The inner loop never learns it is nested.")

## Agent types are Markdown, again

An agent type — “explore,” “recursive,” whatever a project defines — is a Markdown file with YAML frontmatter, the same shape article-07 told the skills story with:

```kotlin
internal fun parseAgentDefinition(content: String, source: Path): AgentDefinition {
    require(content.startsWith("---\n")) { "Agent definition file is missing YAML frontmatter: $source" }
    val lines = content.lines()
    val closeIdx = lines.drop(1).indexOfFirst { it == "---" }
    require(closeIdx >= 0) { "Agent definition file is missing closing '---' delimiter: $source" }
    val yaml = lines.drop(1).take(closeIdx).joinToString("\n")
    val body = lines.drop(closeIdx + 2).joinToString("\n").trim()
    require(body.isNotEmpty()) { "Agent definition file has no system prompt body: $source" }
    val metadata = Yaml.default.decodeFromString(AgentDefinitionMetadata.serializer(), yaml)
    return AgentDefinition(
        name = metadata.name, description = metadata.description,
        systemPrompt = body, allowedTools = metadata.allowedTools, model = metadata.model
    )
}
```

Same recipe-card shape, same reason: a new specialist type shouldn’t require recompiling the harness, any more than a new dish should require rebuilding the kitchen. But this parser is not the one from sophi-skills — it's its own ~35 lines, hand-rolled, living in sophi-core. That's a deliberate refusal to reuse, not an oversight. sophi-core stays completely dependency-free of sophi-skills, the way a general contractor's core scheduling process doesn't depend on which specific supplier prints the spec sheets — bare embedders of the agent loop shouldn't inherit a YAML parser they'll never touch just because two unrelated features happen to both read frontmatter. Paying for a ~35-line duplicated parser once was cheaper than the alternative: a real module dependency that every future embedder of sophi-core would carry whether they use agent definitions or not.

## Least privilege by construction, not by convention

Scoping what a subcontractor can touch doesn’t need a permission system if you never hand them keys to the rooms they shouldn’t be in. ToolRegistry.subset() is the whole mechanism, four lines long:

```kotlin
fun subset(names: List<String>): ToolRegistry {
    val filtered = ToolRegistry()
    names.forEach { name -> tools[name]?.let { filtered.register(it) } }
    return filtered
}
```

There’s no runtime check anywhere that says “is this subagent allowed to call write_file?" There doesn't need to be — if write_file was never put in the filtered registry, the model calling the subagent's nested loop has no way to even discover it exists. It's not denied. It's not in the building.

Recursion gets the same treatment. A definition can only re-enable delegate_to_subagent for its own subagent by explicitly listing it in its own allowedTools — closed by default, reopened only by a definition that names the door on purpose:

```kotlin
if (definition.allowedTools.contains(DELEGATE_TOOL_NAME)) {
    scopedRegistry.register(
        SubagentTool(
            definitions = definitions, provider = provider, fullRegistry = fullRegistry,
            sessionManager = sessionManager, parentConfig = parentConfig,
            contextWindowTokens = contextWindowTokens,
            depth = depth + 1, maxDelegationDepth = maxDelegationDepth, confirmationPolicy = confirmationPolicy
        )
    )
}
```

paired with a hard ceiling checked before any of that even runs:

```kotlin
if (depth >= maxDelegationDepth) {
    return "Error: max delegation depth ($maxDelegationDepth) exceeded"
}
```

A subcontractor who’s allowed to bring in a sub-subcontractor still can’t do it forever — maxDelegationDepth defaults to 3, a chain of specialists three calls deep and no further, closed by a number rather than trusting everyone downstream to know when to stop.

![The main agent’s tool runs at depth 0. A nested SubagentTool exists only if the definition listsdelegate_to_subagent, and each one is built at depth + 1. The fourth link in the chain gets an error string instead of a loop: at most three subagents deep.](image-3.webp "The main agent’s tool runs at depth 0. A nested SubagentTool exists only if the definition listsdelegate_to_subagent, and each one is built at depth + 1. The fourth link in the chain gets an error string instead of a loop: at most three subagents deep.")

That recursive constructor call used to also carry parentSessionId — a value fixed once, at the outer SubagentTool's own construction. It doesn't anymore. The same ADR that gave SkillTool a new home in article-07 made sophi-companion capable of running many concurrent chat sessions through one shared tool registry, and a subagent tool built with one hardcoded parentSessionId baked in at construction time can't serve a registry shared across sessions like that. execute() reads it fresh instead, from whichever turn is actually in flight:

```kotlin
val parentSessionId = coroutineContext[SessionIdContext]?.sessionId
    ?: error("no SessionIdContext — the host must wrap turns in it")
```

Same “least privilege by construction” instinct turned on the tool’s own identity, not just what it’s allowed to touch: a SubagentTool no longer *can* misattribute a delegation to the wrong session, because it never holds a session id to get wrong in the first place — it asks the coroutine it's actually running inside of.

## The subcontractor’s own estimate isn’t taken on faith

delegate_to_subagent grew one more argument when tiered confirmation arrived: expected_tools, a list the calling model fills in itself — "I expect this subagent will need write_file and fetch_url." The obvious move is to trust that list and pre-approve exactly what it names, and for its first month SoPhi did exactly that:

```kotlin
grants = args.expectedTools?.toSet() ?: emptySet(),
```

The delegation call itself was already classified by the worst tool it named, so the user was asked once: “delegate to a subagent that expects write_file?" But a yes to that question became a blanket pass. Every write_file call the subagent then made ran without a prompt, with arguments nobody had seen, because the grant was decided before any real call existed. The subcontractor's own estimate had become their key to the electrical panel. PR #56 on 2026-08-21 closed it, under the plain title "self grant bypass", and the grant now goes through a filter:

```kotlin
fun safeGrantsFrom(expectedTools: List<String>?): Set<String> =
    expectedTools.orEmpty().filter { getOrNull(it)?.riskLevel("{}") == RiskLevel.SAFE }.toSet()
```

```kotlin
fun worstRiskAmong(expectedTools: List<String>?): RiskLevel {
    val tiers = expectedTools.orEmpty().mapNotNull { getOrNull(it)?.riskLevel("{}") }
    return when {
        RiskLevel.DESTRUCTIVE in tiers -> RiskLevel.DESTRUCTIVE
        RiskLevel.CAUTION in tiers -> RiskLevel.CAUTION
        else -> RiskLevel.SAFE
    }
}
```

Both helpers live on ToolRegistry because the same PR found the same pattern duplicated in DecomposeGoalTool, which delegates goal steps with self-declared tools too. safeGrantsFrom only ever auto-grants the tools on that list that were already going to be boring — read_file, not write_file — the same way a GC hands a subcontractor a key to the supply closet without a phone call, but still wants to hear about it before anyone touches the electrical panel. Naming write_file in expected_tools buys it nothing by itself; the subagent still has to ask, the same as if it had never declared anything.

And declaring a dangerous tool isn’t free, either — it’s the same “granting power is itself classified” idea article-06 told about manage_scheduled_task. SubagentTool's own riskLevel() reports worstRiskAmong(args.expectedTools), so a delegation call that names a DESTRUCTIVE tool in its estimate is *itself* DESTRUCTIVE — the parent's own confirmation gate sees a request to hire a subcontractor for electrical work as electrical work, before a single wire gets touched.

![The model’s own estimate is read twice. safeGrantsFrom keeps only the SAFE names as grants inside the subagent, so here only read_file is pre-approved. worstRiskAmong takes the most dangerous name and makes the delegation call itself DESTRUCTIVE, so the parent's gate asks before the subagent exists. Before PR #56, every listed name became a grant.](image-4.webp "The model’s own estimate is read twice. safeGrantsFrom keeps only the SAFE names as grants inside the subagent, so here only read_file is pre-approved. worstRiskAmong takes the most dangerous name and makes the delegation call itself DESTRUCTIVE, so the parent's gate asks before the subagent exists. Before PR #56, every listed name became a grant.")

## Every subcontractor’s job gets a paper trail

A subagent’s session isn’t a scratch buffer that gets thrown away when the delegation returns — it’s a real, persisted AgentSession, tagged with whoever hired it, the same way a subcontractor's invoice references the original job number without touching the GC's own paperwork format. FileSessionManager keeps that link in a small sidecar file that never touches the JSONL entry format article-05 told the whole story of:

```kotlin
override fun create(title: String?, parentSessionId: String?): AgentSession =
    AgentSession(id = UUID.randomUUID().toString(), title = title, parentSessionId = parentSessionId)
```

```kotlin
override fun save(session: AgentSession) {
    // ...
    val current = readSidecar(session.id)
    writeSidecar(session.id, current.copy(
        parentSessionId = session.parentSessionId ?: current.parentSessionId,
        title = session.title ?: current.title
    ))
}
```

parentSessionId lives in <session-id>.meta.json, entirely separate from the <session-id>.jsonl file holding the actual conversation entries — the append-only log article-05 built stays exactly as append-only as it always was, and the "which job hired this subcontractor" fact rides alongside it in its own small file instead of getting bolted onto an entry format that was never designed to carry it. Nobody has to explain a mystery session in a debugging log; every subagent run traces back to the exact turn that spawned it.

Error handling for all of this turns out to already be free, by way of a detail from article-06: AgentLoop's tool dispatch already wraps every tool.execute() call in runCatching. If a nested AgentLoop.turn() throws insideSubagentTool.execute(), the *outer* loop's own dispatch catches it and turns it into an "Error: ..." string the model can see and recover from — the same convention every other tool in the codebase already follows. SubagentTool didn't need its own exception-handling story. It just needed to be an honest tool.

## What we deliberately didn’t build yet

Two adjacent, larger pieces got scoped out on purpose, not by accident. Tools that let an agent act directly on the host computer — filesystem, shell, the things a subagent’s *own* tool registry might eventually include — and an external gateway that could trigger the main agent from outside a chat session entirely, both came later. The dependency order wasn’t arbitrary: an orchestrator that can call in a specialist and trust the boundary around what that specialist can touch has to exist before it’s safe to hand any specialist sharper tools. You don’t give the subcontractor a company credit card before you’ve built a way to scope what they’re allowed to buy with it.

The month the self-grant hole stayed open is the part worth keeping from this story. The nesting was right on day one; the scoping around it needed a second review a month later, because a grant decided before any real call exists is a grant on arguments nobody has seen. The next article starts the same kind of watching for the whole agent: a recorder wired into the same tool calls, writing down what every subcontractor actually did

## What’s next?

The next article in this series covers building a learning system, or as the title refers to it: **The hooks were already there: outcome capture without touching the agent loop**

Find the full source on [GitHub](https://github.com/shukriev/SoPhi) and connect with me on [LinkedIn](https://www.linkedin.com/in/shukri-shukriev-61b26882/).
