Skip to content
Draft
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
23 changes: 23 additions & 0 deletions internal/manifest/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
26 changes: 20 additions & 6 deletions internal/pipeline/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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 ──────────────────────────────────────
Expand Down Expand Up @@ -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 {
Expand Down
100 changes: 77 additions & 23 deletions internal/pipeline/executor_schema_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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")
Expand All @@ -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()

Expand All @@ -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")
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand All @@ -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")
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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")
}
Expand Down Expand Up @@ -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")
}
Expand All @@ -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")
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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")
}

Expand All @@ -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`")
Expand Down Expand Up @@ -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`")
}
Expand All @@ -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")
}
Expand All @@ -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")
Expand Down Expand Up @@ -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")
Expand All @@ -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")
Expand All @@ -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)
}

Expand All @@ -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")
Expand Down
6 changes: 3 additions & 3 deletions internal/pipeline/executor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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 ./...")
Expand All @@ -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)
}

Expand Down
Loading
Loading