Expand description
AgentBlockInProcessSpawnerFactory — in-process headless LLM
agent execution over the agent-block-core SDK.
§Design responsibility — a state-less factory
The factory is a kind-level general-purpose builder — the
process-wide infrastructure layer. It does not carry per-agent
specialisation (script / system_prompt / tools); all agent
specialisation belongs to AgentDef.spec + AgentDef.profile. The
old default_script_path / default_project_root fields were
removed — they were the collision source when a single process
hosts multiple agent.md files.
§Two modes (via ScriptSource)
| Mode | Trigger | Chunk |
|---|---|---|
| PromptBasedAgent (default) | spec.script_path absent | ScriptSource::Inline — the invoker build_inline_agent_invoker generates, which calls the SDK’s agent StdPkg module with the resolved mcp_servers embedded. NOT the SDK’s own DefaultAgent: that one passes no tools, so a frontmatter tools: line could never reach the model. |
| ScriptBasedAgent | spec.script_path = "<path>" | ScriptSource::Path(...) — a caller-provided Lua script, handed to the SDK verbatim. |
Neither mode requires the script to agree on an event-kind string: the
host_handler sink takes any kind as the terminal result (see
“Per-task input and result” below for the one reserved exception).
profile.system_prompt (the agent.md body) is injected into the
_CONTEXT Lua global through BlockConfig.context, and applies to
both modes.
§Spec shape (AgentDef.spec)
This is the settled spec contract for AgentKind::AgentBlock (GH
#86) — every key is optional, and every key the factory reads is
listed here:
{
"project_root": "<path>", // optional, default = std::env::current_dir()
"script_path": "<path>", // optional; absent => PromptBasedAgent mode
"mcp_rpc_timeout_ms": 30000, // optional, default = 30s
"mcp_servers": [ // optional; the pool the tool grant selects from
{ "name": "outline", "command": "outline-mcp", "args": [] }
]
}§Tool grant (GH #86)
The factory reads the effective tool set off profile.tools — which
is already the resolved Runner::AgentBlockInProcess.tools
whenever the agent declares that Runner, because
compiler::project_bound_agent_for_legacy_factories overwrites
profile.tools from the immutable BoundAgent snapshot (including
with an empty list, so a Blueprint can revoke an agent.md’s inherited
tools: line). No Runner declared → the agent.md line stands. There
is deliberately no build hint for this axis: re-deriving the Runner
here would bypass the pinned snapshot and let a Blueprint.runners
edit change an in-flight Run’s grant on resume.
Enforcement is per mode:
| Mode | Enforcement |
|---|---|
| PromptBasedAgent | Enforced at server granularity. Only the spec.mcp_servers entries named by an mcp__<server>__<tool> entry of the effective set are embedded into the invoker (resolve_needed_mcp_servers), so the LLM cannot reach an unlisted server — but it CAN reach every tool of a listed one (the SDK exposes a connected server’s full tool list). Grant per server, not per tool. |
| ScriptBasedAgent | Not enforceable — the script drives its own mcp.connect. Declared mcp__ entries are therefore rejected at compile time rather than silently ignored; drop them and let the script own its connections. |
Non-mcp__-prefixed names (Read / Write / WebSearch) do not
select an MCP server and are inert in both modes (see
[mcp_tools_of]) — the opts.extra_tools carry noted on
resolve_needed_mcp_servers.
§Per-task input and result (GH #86)
Task context reaches this backend through one seam:
WorkerInvocation::context, the in-process twin of
WorkerPayload.context, filled once by InProcSpawner::spawn from the
materialized AgentContextView. Nothing here peeks at Ctx
directly, and no SpawnerAdapter wrapper re-resolves it — every
Lua-visible surface below is derived from that one value:
| Lua surface | Source |
|---|---|
_PROMPT | The step’s evaluated in, via inv.prompt → BlockConfig.prompt. A String — a structured in arrives JSON-stringified, so a script that wants a table calls std.json.decode(_PROMPT). |
_CONTEXT | profile.system_prompt, via BlockConfig.context. |
TASK_METADATA_GLOBAL (_TASK_METADATA) | view.task_metadata (the launch’s init_ctx.task_metadata bag), set through the SDK’s extra_globals — converted natively, so nesting survives and the chunk text is never rewritten. |
AGENT_CTX_GLOBAL (_AGENT_CTX) | view.extra — the Blueprint-declared agent context (default_agent_ctx / AgentMeta.ctx, GH #21) after ContextPolicy filtering. |
Both come from context_globals, the single place this mapping is
decided; the Lua in-process worker renders the same two globals from
it, so a gate is portable between the two backends. view.steps
(prior-step OUTPUT pointers) is deliberately NOT rendered — see the
carrier note in crate::core::agent_context.
No server-process env is involved in any of them. The per-task working
directory is not a Lua global — it becomes the SDK’s project_root
(see the next section), which surfaces to a script as
std.env.project_root() and as the default cwd of sh.exec and of
MCP servers spawned by mcp.connect. It does NOT chdir the host
process, so a bare io.open("rel/path") still resolves against the
server’s own cwd.
A script returns its result by calling bus.emit(<kind>, payload) —
not by returning a value from the chunk. Two destinations:
| emit kind | Effect |
|---|---|
ARTIFACT_EVENT_KIND (artifact) | Stages a named part through WorkerInvocation::sink ({name = ..., content = ...}) and leaves the invocation running. Any number of these. |
| anything else | The terminal result, first emit wins. [WorkerResultCaptor] normalises the payload into WorkerResult.value (payload.content → payload.response → the whole payload). |
Both verdict channels are therefore reachable:
VerdictChannel::Body compares the terminal value, and
VerdictChannel::Part compares a staged "verdict" part — stage it
with bus.emit("artifact", {name = "verdict", content = "PASS"})
before the terminal emit, and the plain body stays free for the
report.
§project_root resolution (issue #17, GH #20)
spec.project_root (above) is only the compile-time fallback
tier — resolved once in [AgentBlockInProcessSpawnerFactory::build],
before any Ctx exists. Per invocation, [resolve_project_root]
applies the task-context tier off WorkerInvocation::context (GH
#20 Contract C — see crate::core::agent_context for the full
narrative) with this priority (highest first):
view.work_dir— Task-level, set byTaskInputMiddlewarefrom the launch’sinit_ctx.work_dir.view.project_root— same middleware,init_ctx.project_root.spec.project_root/std::env::current_dir()(the compile-time fallback baked into [AgentBlockSettings] above).
This lets a single Blueprint’s AgentDef.spec.project_root (fixed at
compile time) be overridden per task launch, so the same Blueprint
can run against different caller-supplied project roots without a
spec edit.
§SDK paths introduced from v0.22.0 through v0.27.0
| Version | Feature | Use case |
|---|---|---|
| v0.22.0 | bus.emit(kind, payload, id?) Lua bridge | script → host event push |
| v0.23.0 | BlockConfig.host_handlers | Pre-install a Rust handler on the EventBus |
| v0.24.0 | BlockConfig.auto_serve_bus | SDK embed drives the dispatcher in the background |
| v0.25.0 | BlockConfig.shutdown_token + BlockError::Cancelled + Send on run() | tokio::spawn and external cancel |
| v0.26.0 | ScriptSource / PromptSource / SecretKeySource enums plus the embedded DefaultAgent invoker (breaking) | Script becomes optional at the SDK level |
| v0.27.0 | Embed the compile_loop StdPkg into core | require("compile_loop") hits directly |
Structs§
- Agent
Block InProcess Spawner Factory - The
SpawnerFactoryfor AgentBlock.KIND = AgentKind::AgentBlock. - Agent
Block Worker - Concrete Worker type for the AgentBlock kind — the handle for an
LLM call routed through the
agent-block-coreSDK. Embeds aWorkerJoinHandlerto carry the async signal. The intent is to eventually keep the SDK-specific quirks — theagent_resultevent name, payload shape, shutdown-token bridging, agent_result.content normalisation — contained inside this struct. Today it lands as a thin shape holding only the async signal; Phase B adds the normalisation layer here and structurally eliminates the token-boilerplate waste observed in observation #2.
Constants§
- AGENT_
CTX_ GLOBAL - The Lua global carrying the Blueprint-declared agent context — the
AgentContextView.extrabag, whichAgentContextMiddlewarefills fromBlueprint.default_agent_ctx/AgentMeta.ctx(GH #21) after applyingContextPolicy. - ARTIFACT_
EVENT_ KIND - The one
bus.emitkind this backend reserves: an emit under it stages a named part instead of completing the invocation (GH #86). - TASK_
METADATA_ GLOBAL - The Lua global carrying the launch’s
init_ctx.task_metadatabag into a script, set through the SDK’sextra_globals(agent-block-core v0.30+).
Functions§
- build_
inline_ agent_ invoker - Build the inline Lua script used on the PromptBasedAgent path (when
spec.script_pathis absent). Instead of the SDK’s embeddedDEFAULT_AGENT_INVOKER(which passes no tools), this embedsmcp_serversas a Lua literal table and hands it toagent.run. - context_
globals - The Lua globals this backend derives from the materialized context
view — the single place the mapping “view field → Lua global” is
decided, so
crate::blueprint::compiler’s Lua worker can render the same surface and keep a gate portable between the two in-process backends. - resolve_
needed_ mcp_ servers - Cross-reference the agent’s declared tool set (see
[
resolve_effective_tools] for which tier that comes from) withspec.mcp_servers(the"server name" → command + argsmapping provided by theAgentDefliteral cascade) and resolve themcp_serversconfig actually exposed to the LLM for this invocation.