From 7c20cdaf941eff27659d6a3f3691d68d90a76818 Mon Sep 17 00:00:00 2001 From: Michael Czechowski Date: Tue, 28 Apr 2026 11:25:08 +0200 Subject: [PATCH 1/2] feat(prompt): runtime.prompt.inline_schemas flag, default true MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The contract prompt builder dumps the full json_schema body inline (`buildContractPrompt`, executor.go:~4940). On a 50KB shared schema, that consumes ~12-15K tokens — fine for frontier models, fatal for local Ollama models capped at 32K context. With the dump in place, glm-4.7-flash hangs on `audit-doc-scan` indefinitely (GPU 100%, 0 tokens emitted). The skeleton + required-fields hint that the same function generates right after is enough for any model that handled the YAML pipeline at all. The full schema is the wasteful part. - Add `runtime.prompt.inline_schemas *bool` to the manifest. Default true (no behavior change for existing repos). Set false to drop the full dump and keep only the schema-path reference + skeleton. - Pass `&execution.Manifest.Runtime.Prompt` through to `buildContractPrompt`. The function takes a third `*PromptConfig` arg; nil preserves historical behavior so callers in tests stay untouched. - New table-style test `TestContractPrompt_InlineSchemasDisabled` asserts: skeleton + required-fields kept, schema reference kept, full schema body dropped. --- internal/manifest/types.go | 23 +++++ internal/pipeline/executor.go | 26 ++++-- internal/pipeline/executor_schema_test.go | 100 +++++++++++++++++----- internal/pipeline/executor_test.go | 6 +- 4 files changed, 123 insertions(+), 32 deletions(-) diff --git a/internal/manifest/types.go b/internal/manifest/types.go index 5832b2511..7615c4f39 100644 --- a/internal/manifest/types.go +++ b/internal/manifest/types.go @@ -172,6 +172,29 @@ type Runtime struct { Cost CostConfig `yaml:"cost,omitempty"` Fallbacks map[string][]string `yaml:"fallbacks,omitempty"` // Adapter fallback chains (e.g., anthropic: [openai, gemini]) StallTimeout string `yaml:"stall_timeout,omitempty"` // Duration string (e.g. "30m", "1800s"). 0 or empty = disabled. + Prompt PromptConfig `yaml:"prompt,omitempty"` // Prompt-construction tuning knobs. +} + +// PromptConfig controls how the executor builds the prompt sent to adapters. +// All fields default to the historical behavior so omitting the block is a no-op. +type PromptConfig struct { + // InlineSchemas controls whether the full json_schema contract content is + // inlined into the prompt as a fenced code block. When false, the executor + // keeps the lighter required-fields skeleton and a reference to the schema + // path, dropping ~50KB per step. Default: true (preserves existing behavior). + // + // Set to false for local-Ollama runs where the schema dump consumes a large + // fraction of the model's context window. + InlineSchemas *bool `yaml:"inline_schemas,omitempty"` +} + +// InlineSchemasEnabled reports whether full json_schema contracts should be +// inlined into the prompt. Defaults to true when unset. +func (p PromptConfig) InlineSchemasEnabled() bool { + if p.InlineSchemas == nil { + return true + } + return *p.InlineSchemas } // CostConfig holds cost tracking and budget enforcement settings. diff --git a/internal/pipeline/executor.go b/internal/pipeline/executor.go index bfcceca20..2d75cb26e 100644 --- a/internal/pipeline/executor.go +++ b/internal/pipeline/executor.go @@ -3406,7 +3406,7 @@ func (e *DefaultPipelineExecutor) buildStepAdapterConfig(_ context.Context, exec // Auto-generate contract compliance section. Appended directly to the user prompt // so the model sees it alongside the task instructions (system prompt injection was unreliable). - contractPrompt := e.buildContractPrompt(step, execution.Context) + contractPrompt := e.buildContractPrompt(step, execution.Context, &execution.Manifest.Runtime.Prompt) if contractPrompt != "" { prompt = prompt + "\n\n" + contractPrompt } @@ -4899,7 +4899,11 @@ func (e *DefaultPipelineExecutor) trackStepDeliverables(execution *PipelineExecu // // This is the SINGLE source of truth for schema injection — it includes security // validation (path traversal, content sanitization) and the full schema content. -func (e *DefaultPipelineExecutor) buildContractPrompt(step *Step, ctx *PipelineContext) string { +// +// promptCfg controls inline-schema behavior. Pass nil to preserve historical +// behavior (full schema dump). Callers in production go through +// runStep, which threads `&execution.Manifest.Runtime.Prompt`. +func (e *DefaultPipelineExecutor) buildContractPrompt(step *Step, ctx *PipelineContext, promptCfg *manifest.PromptConfig) string { var b strings.Builder // ── Output artifact guidance ────────────────────────────────────── @@ -4939,10 +4943,20 @@ func (e *DefaultPipelineExecutor) buildContractPrompt(step *Step, ctx *PipelineC // time, which surfaces the real error. schemaContent, _ := e.sec.loadSecureSchemaContent(step) if schemaContent != "" { - // Include the full schema for the persona to reference - b.WriteString("**Schema** (your output must conform to this):\n```json\n") - b.WriteString(schemaContent) - b.WriteString("\n```\n\n") + inlineSchemas := true + if promptCfg != nil { + inlineSchemas = promptCfg.InlineSchemasEnabled() + } + if inlineSchemas { + // Include the full schema for the persona to reference + b.WriteString("**Schema** (your output must conform to this):\n```json\n") + b.WriteString(schemaContent) + b.WriteString("\n```\n\n") + } else if step.Handover.Contract.SchemaPath != "" { + // Skip the heavy inline dump but tell the model where the + // authoritative schema lives so it can be inspected on demand. + b.WriteString(fmt.Sprintf("**Schema reference**: `%s` (the validator enforces this; the skeleton below covers the required shape).\n\n", step.Handover.Contract.SchemaPath)) + } // Also extract required fields and build a skeleton example var schema struct { diff --git a/internal/pipeline/executor_schema_test.go b/internal/pipeline/executor_schema_test.go index 8f7b3214a..43fc5df04 100644 --- a/internal/pipeline/executor_schema_test.go +++ b/internal/pipeline/executor_schema_test.go @@ -9,6 +9,7 @@ import ( "time" "github.com/recinq/wave/internal/adapter" + "github.com/recinq/wave/internal/manifest" "github.com/recinq/wave/internal/security" "github.com/recinq/wave/internal/testutil" "github.com/stretchr/testify/assert" @@ -47,7 +48,7 @@ func TestContractPrompt_ValidFileSchema(t *testing.T) { }, } - prompt := executor.buildContractPrompt(step, nil) + prompt := executor.buildContractPrompt(step, nil, nil) // Verify output requirements and contract schema sections assert.Contains(t, prompt, "Output Requirements") @@ -68,6 +69,59 @@ func TestContractPrompt_ValidFileSchema(t *testing.T) { } // TestContractPrompt_InlineSchema tests that inline schemas are included in the contract prompt. +// TestContractPrompt_InlineSchemasDisabled verifies that the +// runtime.prompt.inline_schemas=false toggle drops the full schema dump but +// keeps the schema-path reference and the required-fields skeleton. +func TestContractPrompt_InlineSchemasDisabled(t *testing.T) { + tmpDir := t.TempDir() + + schemaContent := `{ + "type": "object", + "required": ["name", "version"], + "properties": { + "name": {"type": "string"}, + "version": {"type": "string"} + } +}` + schemaPath := filepath.Join(tmpDir, "contracts", "test.schema.json") + require.NoError(t, os.MkdirAll(filepath.Dir(schemaPath), 0755)) + require.NoError(t, os.WriteFile(schemaPath, []byte(schemaContent), 0644)) + + executor := createSchemaTestExecutor(tmpDir) + + step := &Step{ + ID: "step1", + OutputArtifacts: []ArtifactDef{ + {Name: "output", Path: ".agents/output/result.json"}, + }, + Handover: HandoverConfig{ + Contract: ContractConfig{ + Type: "json_schema", + SchemaPath: schemaPath, + }, + }, + } + + disabled := false + prompt := executor.buildContractPrompt(step, nil, &manifest.PromptConfig{InlineSchemas: &disabled}) + + // Skeleton + required fields must still appear — that is enough for the + // model to produce conforming JSON. + assert.Contains(t, prompt, "Required fields") + assert.Contains(t, prompt, "Example structure") + assert.Contains(t, prompt, "`name`, `version`") + + // Schema path reference is kept as a pointer to the authoritative source. + assert.Contains(t, prompt, "Schema reference") + assert.Contains(t, prompt, schemaPath) + + // The full schema body must NOT be inlined when the flag is off. + assert.NotContains(t, prompt, `"properties": {`, + "full schema dump should be skipped when inline_schemas=false") + assert.NotContains(t, prompt, "**Schema** (your output must conform to this)", + "inline-schema header should be skipped when the dump is off") +} + func TestContractPrompt_InlineSchema(t *testing.T) { tmpDir := t.TempDir() @@ -88,7 +142,7 @@ func TestContractPrompt_InlineSchema(t *testing.T) { }, } - prompt := executor.buildContractPrompt(step, nil) + prompt := executor.buildContractPrompt(step, nil, nil) assert.Contains(t, prompt, "Output Requirements") assert.Contains(t, prompt, "Contract Schema") @@ -116,7 +170,7 @@ func TestContractPrompt_MissingSchemaFile(t *testing.T) { }, } - prompt := executor.buildContractPrompt(step, nil) + prompt := executor.buildContractPrompt(step, nil, nil) // Contract prompt is still generated (CRITICAL warning) but no schema content assert.Contains(t, prompt, "Contract Schema") @@ -152,7 +206,7 @@ func TestContractPrompt_PathTraversalAttempt(t *testing.T) { }, } - prompt := executor.buildContractPrompt(step, nil) + prompt := executor.buildContractPrompt(step, nil, nil) // Path traversal should be blocked — no schema content injected assert.NotContains(t, prompt, "etc/passwd") @@ -204,7 +258,7 @@ func TestContractPrompt_PromptInjectionInSchema(t *testing.T) { }, } - prompt := executor.buildContractPrompt(step, nil) + prompt := executor.buildContractPrompt(step, nil, nil) assert.NotContains(t, strings.ToLower(prompt), "ignore previous instructions") assert.NotContains(t, strings.ToLower(prompt), "disregard above") @@ -242,7 +296,7 @@ func TestContractPrompt_LargeSchemaFile(t *testing.T) { }, } - prompt := executor.buildContractPrompt(step, nil) + prompt := executor.buildContractPrompt(step, nil, nil) assert.NotContains(t, prompt, strings.Repeat("x", 100), "Large schema content should not be injected") @@ -275,7 +329,7 @@ func TestContractPrompt_NonJsonSchemaContract(t *testing.T) { }, } - prompt := executor.buildContractPrompt(step, nil) + prompt := executor.buildContractPrompt(step, nil, nil) if tc.contractType == "" { assert.Empty(t, prompt, "Empty contract type should produce empty prompt") @@ -308,7 +362,7 @@ func TestContractPrompt_SchemaPathPrecedence(t *testing.T) { }, } - prompt := executor.buildContractPrompt(step, nil) + prompt := executor.buildContractPrompt(step, nil, nil) assert.Contains(t, prompt, `"source":"file"`, "File schema should be used") assert.NotContains(t, prompt, `"source":"inline"`, "Inline schema should not be used when SchemaPath is provided") @@ -330,7 +384,7 @@ func TestContractPrompt_EmptySchema(t *testing.T) { }, } - prompt := executor.buildContractPrompt(step, nil) + prompt := executor.buildContractPrompt(step, nil, nil) // Contract Schema header appears (contract type is json_schema) but no actual schema content assert.Contains(t, prompt, "Contract Schema") @@ -370,7 +424,7 @@ func TestContractPrompt_SpecialCharactersInSchema(t *testing.T) { }, } - prompt := executor.buildContractPrompt(step, nil) + prompt := executor.buildContractPrompt(step, nil, nil) assert.Contains(t, prompt, "Schema", "Schema with special chars should be injected") assert.Contains(t, prompt, "email", "Schema content should be present") @@ -412,7 +466,7 @@ func TestContractPrompt_MustPassPromptInjection(t *testing.T) { }, } - prompt := executor.buildContractPrompt(step, nil) + prompt := executor.buildContractPrompt(step, nil, nil) assert.NotContains(t, prompt, "ignore previous instructions") } @@ -521,7 +575,7 @@ func TestContractPrompt_RelativeSchemaPath(t *testing.T) { }, } - prompt := executor.buildContractPrompt(step, nil) + prompt := executor.buildContractPrompt(step, nil, nil) assert.Contains(t, prompt, "Schema", "Relative path should work for allowed directories") } @@ -546,7 +600,7 @@ func TestContractPrompt_InvalidJSONSchema(t *testing.T) { }, } - prompt := executor.buildContractPrompt(step, nil) + prompt := executor.buildContractPrompt(step, nil, nil) // Invalid JSON should still be included (validation happens at contract validation time) assert.Contains(t, prompt, "Schema", "Invalid JSON content should still be injected") @@ -579,7 +633,7 @@ func TestContractPrompt_UnicodeInSchema(t *testing.T) { }, } - prompt := executor.buildContractPrompt(step, nil) + prompt := executor.buildContractPrompt(step, nil, nil) assert.Contains(t, prompt, "Schema", "Unicode schema should be injected") assert.Contains(t, prompt, "Schema with Unicode", "Schema description should be present") @@ -616,7 +670,7 @@ func TestContractPrompt_SecurityLogging(t *testing.T) { }, } - prompt := executor.buildContractPrompt(step, nil) + prompt := executor.buildContractPrompt(step, nil, nil) assert.Contains(t, prompt, "Schema", "Schema should be injected with logging enabled") } @@ -641,7 +695,7 @@ func TestContractPrompt_ArtifactGuidance(t *testing.T) { }, } - prompt := executor.buildContractPrompt(step, nil) + prompt := executor.buildContractPrompt(step, nil, nil) assert.Contains(t, prompt, "Available Artifacts") assert.Contains(t, prompt, "`research_data` → `.agents/artifacts/research_data`") @@ -669,7 +723,7 @@ func TestContractPrompt_ArtifactGuidanceUsesArtifactNameWhenNoAs(t *testing.T) { }, } - prompt := executor.buildContractPrompt(step, nil) + prompt := executor.buildContractPrompt(step, nil, nil) assert.Contains(t, prompt, "`raw-data` → `.agents/artifacts/raw-data`") } @@ -690,7 +744,7 @@ func TestContractPrompt_NoArtifactGuidanceWhenNoInjections(t *testing.T) { }, } - prompt := executor.buildContractPrompt(step, nil) + prompt := executor.buildContractPrompt(step, nil, nil) assert.NotContains(t, prompt, "Available Artifacts") } @@ -714,7 +768,7 @@ func TestContractPrompt_JsonOutputWithoutContract(t *testing.T) { // NOTE: No Handover.Contract at all } - prompt := executor.buildContractPrompt(step, nil) + prompt := executor.buildContractPrompt(step, nil, nil) // Should still generate output requirements assert.Contains(t, prompt, "Output Requirements") @@ -742,7 +796,7 @@ func TestContractPrompt_MarkdownOutputWithoutContract(t *testing.T) { }, } - prompt := executor.buildContractPrompt(step, nil) + prompt := executor.buildContractPrompt(step, nil, nil) assert.Contains(t, prompt, "Output Requirements") assert.Contains(t, prompt, ".agents/output/report.md") @@ -763,7 +817,7 @@ func TestContractPrompt_MultipleOutputArtifacts(t *testing.T) { }, } - prompt := executor.buildContractPrompt(step, nil) + prompt := executor.buildContractPrompt(step, nil, nil) assert.Contains(t, prompt, ".agents/output/pr-result.json") assert.Contains(t, prompt, ".agents/output/summary.md") @@ -780,7 +834,7 @@ func TestContractPrompt_NoOutputsNoContract(t *testing.T) { ID: "step1", } - prompt := executor.buildContractPrompt(step, nil) + prompt := executor.buildContractPrompt(step, nil, nil) assert.Empty(t, prompt) } @@ -800,7 +854,7 @@ func TestContractPrompt_InjectArtifactsOnly(t *testing.T) { }, } - prompt := executor.buildContractPrompt(step, nil) + prompt := executor.buildContractPrompt(step, nil, nil) assert.NotEmpty(t, prompt) assert.Contains(t, prompt, "Available Artifacts") diff --git a/internal/pipeline/executor_test.go b/internal/pipeline/executor_test.go index a2a9ea699..2aaedeaca 100644 --- a/internal/pipeline/executor_test.go +++ b/internal/pipeline/executor_test.go @@ -561,7 +561,7 @@ func TestBuildContractPrompt_JSONSchema(t *testing.T) { }, } - prompt := executor.buildContractPrompt(step, nil) + prompt := executor.buildContractPrompt(step, nil, nil) assert.Contains(t, prompt, "Output Requirements") assert.Contains(t, prompt, "artifact.json") @@ -586,7 +586,7 @@ func TestBuildContractPrompt_TestSuite(t *testing.T) { }, } - prompt := executor.buildContractPrompt(step, nil) + prompt := executor.buildContractPrompt(step, nil, nil) assert.Contains(t, prompt, "Test Validation") assert.Contains(t, prompt, "go test ./...") @@ -600,7 +600,7 @@ func TestBuildContractPrompt_NoContract(t *testing.T) { step := &Step{ID: "test-step"} - prompt := executor.buildContractPrompt(step, nil) + prompt := executor.buildContractPrompt(step, nil, nil) assert.Empty(t, prompt) } From 049cda073dc11303f16090a2abc3257942a7eda4 Mon Sep 17 00:00:00 2001 From: Michael Czechowski Date: Tue, 28 Apr 2026 11:25:25 +0200 Subject: [PATCH 2/2] wave.yaml: timeouts 90m + opt out of inline schema dumps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three knobs aligned for local-Ollama runs on this host: - runtime.default_timeout_minutes: 30 -> 90 - runtime.timeouts.step_default_minutes: 15 -> 90 - runtime.stall_timeout: 10m -> 90m GLM-4.7-flash and qwen3.5:27b take longer than frontier models on real audit prompts. The previous defaults treated even a successful local run as a failure. Plus runtime.prompt.inline_schemas: false (the new flag from the companion commit) — frees ~12-15K tokens per step that GLM was spending on the schema dump alone. Together these unblock audit-* and impl-* on local; without them local was effectively limited to single-step local-* pipelines. --- wave.yaml | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/wave.yaml b/wave.yaml index 6f408ffaa..70f70e444 100644 --- a/wave.yaml +++ b/wave.yaml @@ -470,9 +470,17 @@ runtime: log_all_file_operations: false log_all_tool_calls: true log_dir: .agents/traces/ - default_timeout_minutes: 30 - stall_timeout: 10m + default_timeout_minutes: 90 + stall_timeout: 90m max_concurrent_workers: 5 + prompt: + # Skip the full json_schema dump in the prompt (~50KB per step). The + # required-fields skeleton + schema path reference are kept; that's + # enough for any model that handled the YAML pipeline at all, and it + # frees ~12-15K tokens per step — the difference between a local + # 32K-context Ollama model finishing an audit step and hanging. + # Default for the rest of the world is true (full schema inlined). + inline_schemas: false meta_pipeline: max_depth: 2 max_total_steps: 20 @@ -482,7 +490,7 @@ runtime: strategy: summarize_to_checkpoint token_threshold_percent: 80 timeouts: - step_default_minutes: 15 + step_default_minutes: 90 relay_compaction_minutes: 5 meta_default_minutes: 30 skill_install_seconds: 120