Vancetope — process_create_delegate
A tool that starts a new Think Process without the caller needing to know the Recipe name. Instead, the caller describes the task in natural language; a bundled Selector Recipe selects the appropriate entry from the Project Recipe inventory and delegates to the existing
process_createspawn path. If nothing matches:decision: NONE— the caller decides on the fallback (typically: spawn Slartibartfast for a freshly generated Strategy, or ask the user for clarification).This tool is the answer to the Engine/Recipe selection problem: every caller LLM layer (Arthur, Marvin’s PLAN, Slart’s PROPOSING) today implicitly selected Recipes, often with pattern-bleed effects (“Slart invents Adams-essay-pipeline for note-taking task”). With
process_create_delegate, selection is centralized into an explicit, documented layer.
1. Delimitation — when to use which tool?
Vancetope has two complementary spawn tools for LLM callers:
| Tool | Caller knows | Main Schema Parameter | Use Case |
|---|---|---|---|
process_create |
Recipe name | recipe: <name> |
“Spawn essay-pipeline for me” — Recipe choice is made, here is the spec. |
process_create_delegate |
Task | task: <description> |
“Write me a short essay about X” — System should find suitable Recipe. |
Both exist in parallel — no replacement. The LLM chooses based on what it has. If both are available (Recipe name AND description), the explicit form (process_create) wins — caller intent is clear.
Direct engine: <name> spawn via process_create remains unchanged for advanced cases. process_create_delegate only works with Recipe resolution.
2. Architecture
Caller (Arthur, Marvin's PLAN-LLM, manual CLI call, …)
│
├── process_create_delegate({ name, task, … })
│
▼
ProcessCreateDelegateTool [vance-brain.tools.process]
│
├── RecipeSelectorService.select(callerProcess, task)
│ │
│ ├── EngineCatalog.renderForPrompt() [bundled engines.yaml]
│ ├── RecipeLoader.listAll(tenant, project) [project + tenant + bundled]
│ ├── single LLM call (callerProcess's model preferences)
│ └── parse → { decision: MATCH|NONE, recipe, rationale }
│
▼
├── decision == NONE → return decision (no spawn)
│
└── decision == MATCH → ProcessCreateTool.invoke({ recipe: <picked>, … })
│
▼
regular spawn path with cascade resolution,
profile inheritance, engine start, optional
initial steer.
Three important characteristics:
- Synchronous LLM call, no sub-process spawn for selection. One round-trip per
delegatecall. - Existing
ProcessCreateToolas spawn backend — no duplicated spawn logic. After successful selection, nothing differs from a directprocess_createcall. NONEis first-class, not an error. The caller receives the Decision object back and decides on the fallback.
3. Tool Contract
3.1 Schema
{
"name": "<stable process name, unique per session>",
"task": "<natural-language task description>",
"title": "<optional human-readable title>",
"steerContent":"<optional initial USER_CHAT_INPUT>",
"params": { … }
}
name and task are mandatory. title, steerContent, params are passed 1:1 to the downstream process_create call (with the same semantics).
3.2 Output
{
"decision": "MATCH" | "NONE",
"recipe": "<recipe-name>" | null,
"engine": "<engine-name>" | null,
"rationale": "<why this Recipe or why NONE — 1-2 sentences>",
"process": { … } // only if decision=MATCH; the output of ProcessCreateTool
}
If NONE, process is not set — no spawn occurred. The caller LLM can decide based on the rationale whether to call again with a more precise task description, spawn Slartibartfast, or ask the user for clarification.
4. Selector Internals
4.1 Engine Catalog
vance-defaults/catalog/engines.yaml is the curated description of all Engines. Per Engine: purpose, when_to_use, not_for. Loaded statically at EngineCatalog boot time, embedded in the Selector Prompt as a markdown list.
engines:
vogon:
purpose: "Deterministic linear / branching pipeline runner …"
when_to_use: "Workflow shape is known in advance. …"
not_for: "Tasks where the shape of the plan only emerges …"
New Engine? Append an entry. No code change. The Catalog is the source for the “which Engine solves what” heuristic of the Selector.
4.2 Recipe Inventory
RecipeLoader.listAll(tenantId, projectId) aggregates across the Cascade (Project → Tenant → bundled defaults). Only user-pickable Recipes are included in the inventory:
_slart/*excluded (Slart’s own outputs are not human-curated workflows)_*(leading underscore) excluded (system-internal)- Limit
RECIPE_LIST_LIMIT = 50as a cap for prompt size
For each Recipe, name, engine, and description are passed to the Selector Prompt. Recipe description quality is a prerequisite for good Selector performance — thinly described default Recipes tend to be mismatched or ignored.
4.3 Selector Prompt
System Prompt (static):
- Output schema with
decision: MATCH|NONE,recipe,rationale - Decision rules: MATCH only for verbatim Recipe names; NONE if unclear or no fit
- Selection guidance: match on Intent, not surface words; “NONE” is a respectable answer
User Prompt (per call):
- Engine Catalog (rendered)
- Recipe Inventory (list with name/engine/description)
- Task description
- “Pick the matching recipe (or NONE) and emit a single JSON object now.”
4.4 Existence Check as Defense-in-Depth
Even if the Selector Prompt explicitly says “no inventing names”: after parsing, RecipeSelectorService checks if the returned recipe name exists verbatim in the inventory. If not → NONE with diagnostic "unknown recipe '<x>' — not in inventory". Symmetrical to the existing marvin-recipe-allowed-recipes-exist validator in Slart’s VALIDATING phase.
4.5 LLM Configuration
The Selector calls the LLM with the model preferences of the Caller Process — via ChatBehaviorBuilder.fromProcess(callingProcess, settings, resolver). This way, the Selector inherits the same provider/model choice as the caller; no separate configuration is needed.
In tests, buildChat() (package-protected) is overridden to bypass the credential cascade.
5. Decision Flow for the Caller LLM
Does the caller have a concrete Recipe name?
├── Yes → process_create(recipe=<name>, …)
└── No → process_create_delegate(task=<description>, …)
│
▼
decision == ?
├── MATCH → spawned, return { process: …, … }
└── NONE → fallbackOnNone? (default: true)
├── true → SlartibartfastFallback:
│ 1. spawn Slart with task
│ 2. wait for DONE (≤4 min)
│ 3. spawn generated recipe
│ return { decision: NONE, fallback: { … }, process: … }
└── false → return { decision: NONE, rationale: … }
Caller decides manually:
- more precise `task` description
- ask user for clarification
- spawn Slart themselves
The caller LLM chooses between process_create and process_create_delegate based on the tool descriptions. Decision cue: does it have a Recipe name or a task description?
5.1 Fallback Behavior
fallbackOnNone: true (default) makes the tool a full “give-me-a-running-process” routing layer:
- Existing Recipe → 1 LLM call, ~5s Latency
- Slart-Fallback (sync) → 1 LLM call + Slart-Pipeline, ~60-180s Latency
- Slart fails → return NONE with Slart-failure-info, no spawn
fallbackOnNone: false gives the caller full control — suitable for callers who want to query in case of ambiguity instead of blindly proceeding.
SlartibartfastFallback sets a hard wall-clock cutoff (WAIT_TIMEOUT = 4min) — if Slart gets stuck, nothing is spawned.
5.2 Sync vs. Async Fallback
asyncFallback: true (default false): Tool spawns Slart and returns immediately with outcome: PENDING + slartProcessId. Slart runs in the background under the Caller Process (parentProcessId=caller). When Slart reaches CLOSED, the ParentNotificationListener fires a process event into the Caller’s Pending Queue. The Caller observes this and can manually call process_create with the _slart/<runId>/<name> Recipe path.
Auto-spawn of the generated Recipe is omitted in async mode — the tool call is unblocked, the lifecycle of the spawned Recipe is up to the caller. Suitable for caller LLMs that cannot block for 60-180s (e.g., Foot-CLI user sessions).
| Mode | Tool Wait Time | Auto-Spawn | Caller Task |
|---|---|---|---|
| sync (default) | until Slart DONE | yes | nothing — Recipe is spawned |
| async | immediately | no | observe Slart event, spawn Recipe |
6. Failure Modes & Fallbacks
| Failure Mode | Selector Behavior |
|---|---|
Empty task-Description |
NONE with rationale: "empty task description" (no LLM call) |
| Project has no Recipes | NONE with rationale: "no recipes available …" (no LLM call) |
| LLM-Call throws Exception | NONE with rationale: "LLM call failed: <e.getMessage()>" |
| LLM replies empty / without JSON | NONE with rationale: "LLM reply has no JSON object" |
| LLM replies with malformed JSON | NONE with rationale: "JSON parse error: …" |
LLM-Decision == MATCH but recipe hallucinated |
NONE with rationale: "unknown recipe '<x>' — not in inventory" |
LLM-Decision == NONE |
propagated with LLM-rationale |
Pattern: The Selector shifts every edge case to NONE — it never throws, never spawns something incorrect. The caller layer has a consistent failure pattern.
7. What the tool does not do
- No asynchronous disposition. The Slart fallback waits synchronously — the caller LLM is blocked during the Slart pipeline. For long Recipes, an async variant (tool returns immediately, Slart runs in the background, caller polls) would be a separate iteration.
- No Recipe generation within the Selector itself. If nothing existing fits, Slart takes over — the Selector is single-purpose.
- No multi-Recipe match. The Selector selects at most one Recipe. For “do A then B”, the caller must split this into two delegate calls OR find a Recipe that encapsulates both steps.
- No branching into multiple phases. Linear pipeline through one Engine — if phases are needed, that is a Vogon/Marvin Recipe that phases itself.
8. Implementation — Location
| Component | File |
|---|---|
| Engine Catalog (Resource) | vance-brain/src/main/resources/vance-defaults/catalog/engines.yaml |
| Catalog Loader | vance-brain/.../delegate/EngineCatalog.java |
| Selector Service | vance-brain/.../delegate/RecipeSelectorService.java |
| Slart Fallback | vance-brain/.../delegate/SlartibartfastFallback.java |
| Tool | vance-brain/.../tools/process/ProcessCreateDelegateTool.java |
| Unit Tests | vance-brain/src/test/java/de/mhus/vance/brain/delegate/{EngineCatalog,RecipeSelectorService}Test.java |
The tool is automatically registered in BuiltInToolSource via Spring @Component-scan.
9. What’s Next (Open Iterations)
| Item | Description | Status |
|---|---|---|
| E2E Test | Probe tests against UC2/UC3 — empirically shows whether delegate closes the hallucination gap | ✅ done |
| Recipe Description Audit | Bundled default Recipes often have thin descriptions; for better matching: go through and fill them in | ✅ done (engine-default-tag, web-research polish) |
| NONE → Slart Auto-Fallback | Caller-side convenience — if delegate returns NONE, automatically spawn Slart, then spawn generated recipe |
✅ done (fallbackOnNone default true) |
| Caller Migration (Arthur) | DELEGATE-Action: preset now optional, without preset Engine routes through process_create_delegate. Tool ALLOWED_TOOLS extended with delegate. Prompt documentation in arthur-prompt.md/arthur-prompt-small.md updated with both modes and examples |
✅ done |
| Caller Migration (Marvin’s PLAN) | WORKER children may now arrive with or without taskSpec.recipe. Without recipe, runWorker calls RecipeSelectorService with node.goal as task description and uses the returned Recipe for spawning. Validator accepts recipe-less WORKERs (with non-blank goal). PLAN-system-prompt explains both modes. EXPAND_FROM_DOC childTemplate.recipe remains mandatory (separate iteration). |
✅ done (top-level WORKER) |
| Marvin EXPAND childTemplate Migration | childTemplate.recipe is now optional. If omitted: each materialized Child lands recipe-less in Marvin’s Pre-order-DFS, and runWorker’s pickRecipeViaSelector routes it during spawn. Cost: 1 Selector-LLM-Call per item — PLAN-Prompt recommends explicit-binding for very large batches |
✅ done |
| Selector Memoization for EXPAND-Batches | runExpandFromDoc calls the Selector ONCE on the childTemplate-goal-template before DocumentExpander materializes. Picked recipe is burned into the childTemplate — all N Children spawn with the same Recipe without further Selector calls. In case of NONE: graceful Fallback to per-child-Selector. Cost reduction: N Calls → 1 Call per EXPAND-Batch. |
✅ done |
| Async Variant of Fallback | asyncFallback: true parameter on the Tool: spawns Slart and returns immediately with outcome: PENDING + slartProcessId. Caller observes Slart’s terminal event via Parent-Notification (Slart is spawned with parentProcessId=caller). Auto-spawn of the generated recipe is omitted in async mode — Caller is responsible. |
✅ done |
| Catalog Refresh | POST /brain/{tenant}/admin/catalog/engines/reload triggers a re-reading of the bundled vance-defaults/catalog/engines.yaml without JVM restart. Idempotent + thread-safe (synchronized + atomic list-replace). In case of parse error, the old entry list remains active. Tenant-scoped by convention (each Tenant-Admin can trigger); refresh effect is global as Catalog is a Singleton. |
✅ done |
| Tenant Override for Catalog | not planned (architecturally unsuitable) | |
| Multi-Match with Score | council-*-Recipes. Fallback chain handles Slart-Fallback. If a concrete use case arises: address it specifically. |
not planned (solution looking for a problem) |