Skip to content

Commit 7b7f902

Browse files
nhortonclaude
andcommitted
Prevent mixing hook types in job schema validation
Updates the job schema to enforce that each hook event (after_agent, before_tool, before_prompt) can only contain hooks of a single type - either all scripts, all prompts, or all prompt_files. Mixing types like having both a script and a prompt in the same event is now rejected. This prevents confusing configurations where script output would be followed by a prompt evaluation, which doesn't work as expected. Adds tests to verify: - Mixed script/prompt hooks are rejected - Mixed script/prompt_file hooks are rejected - Multiple scripts in same event are allowed - Multiple prompts in same event are allowed Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent eec7c9d commit 7b7f902

2 files changed

Lines changed: 168 additions & 11 deletions

File tree

src/deepwork/schemas/job_schema.py

Lines changed: 70 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,69 @@
66
# These values must match CommandLifecycleHook enum in adapters.py
77
LIFECYCLE_HOOK_EVENTS = ["after_agent", "before_tool", "before_prompt"]
88

9-
# Schema definition for a single hook action (prompt, prompt_file, or script)
9+
# Schema definitions for hook actions - each type is separate to enforce no mixing
10+
PROMPT_HOOK_SCHEMA: dict[str, Any] = {
11+
"type": "object",
12+
"required": ["prompt"],
13+
"properties": {
14+
"prompt": {
15+
"type": "string",
16+
"minLength": 1,
17+
"description": "Inline prompt for validation/action",
18+
},
19+
},
20+
"additionalProperties": False,
21+
}
22+
23+
PROMPT_FILE_HOOK_SCHEMA: dict[str, Any] = {
24+
"type": "object",
25+
"required": ["prompt_file"],
26+
"properties": {
27+
"prompt_file": {
28+
"type": "string",
29+
"minLength": 1,
30+
"description": "Path to prompt file (relative to job directory)",
31+
},
32+
},
33+
"additionalProperties": False,
34+
}
35+
36+
SCRIPT_HOOK_SCHEMA: dict[str, Any] = {
37+
"type": "object",
38+
"required": ["script"],
39+
"properties": {
40+
"script": {
41+
"type": "string",
42+
"minLength": 1,
43+
"description": "Path to shell script (relative to job directory)",
44+
},
45+
},
46+
"additionalProperties": False,
47+
}
48+
49+
# Schema for a hook event - must be all one type (no mixing script and prompt hooks)
50+
# Each hook event is an array containing only one type of hook action
51+
HOOK_EVENT_SCHEMA: dict[str, Any] = {
52+
"oneOf": [
53+
{
54+
"type": "array",
55+
"description": "Array of script hooks only",
56+
"items": SCRIPT_HOOK_SCHEMA,
57+
},
58+
{
59+
"type": "array",
60+
"description": "Array of prompt hooks only",
61+
"items": PROMPT_HOOK_SCHEMA,
62+
},
63+
{
64+
"type": "array",
65+
"description": "Array of prompt_file hooks only",
66+
"items": PROMPT_FILE_HOOK_SCHEMA,
67+
},
68+
],
69+
}
70+
71+
# Legacy schema for backward compatibility in stop_hooks (allows mixing)
1072
HOOK_ACTION_SCHEMA: dict[str, Any] = {
1173
"type": "object",
1274
"oneOf": [
@@ -177,22 +239,19 @@
177239
},
178240
"hooks": {
179241
"type": "object",
180-
"description": "Lifecycle hooks for this step, keyed by event type",
242+
"description": "Lifecycle hooks for this step, keyed by event type. Each event must use only one hook type (script, prompt, or prompt_file) - mixing is not allowed.",
181243
"properties": {
182244
"after_agent": {
183-
"type": "array",
184-
"description": "Hooks triggered after the agent finishes (quality validation)",
185-
"items": HOOK_ACTION_SCHEMA,
245+
**HOOK_EVENT_SCHEMA,
246+
"description": "Hooks triggered after the agent finishes (quality validation). Must be all scripts OR all prompts.",
186247
},
187248
"before_tool": {
188-
"type": "array",
189-
"description": "Hooks triggered before a tool is used",
190-
"items": HOOK_ACTION_SCHEMA,
249+
**HOOK_EVENT_SCHEMA,
250+
"description": "Hooks triggered before a tool is used. Must be all scripts OR all prompts.",
191251
},
192252
"before_prompt": {
193-
"type": "array",
194-
"description": "Hooks triggered when user submits a prompt",
195-
"items": HOOK_ACTION_SCHEMA,
253+
**HOOK_EVENT_SCHEMA,
254+
"description": "Hooks triggered when user submits a prompt. Must be all scripts OR all prompts.",
196255
},
197256
},
198257
"additionalProperties": False,

tests/unit/test_stop_hooks.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,104 @@ def test_valid_hooks_with_script_action(self) -> None:
355355
}
356356
validate_against_schema(job_data, JOB_SCHEMA)
357357

358+
def test_invalid_hooks_mixed_script_and_prompt(self) -> None:
359+
"""Test schema rejects mixing script and prompt hooks in same event."""
360+
job_data = {
361+
"name": "test_job",
362+
"version": "1.0.0",
363+
"summary": "Test job",
364+
"steps": [
365+
{
366+
"id": "step1",
367+
"name": "Step 1",
368+
"description": "A step",
369+
"instructions_file": "steps/step1.md",
370+
"outputs": ["output.md"],
371+
"hooks": {
372+
"after_agent": [
373+
{"script": "hooks/run_tests.sh"},
374+
{"prompt": "Evaluate the test results"},
375+
],
376+
},
377+
}
378+
],
379+
}
380+
with pytest.raises(ValidationError):
381+
validate_against_schema(job_data, JOB_SCHEMA)
382+
383+
def test_invalid_hooks_mixed_script_and_prompt_file(self) -> None:
384+
"""Test schema rejects mixing script and prompt_file hooks in same event."""
385+
job_data = {
386+
"name": "test_job",
387+
"version": "1.0.0",
388+
"summary": "Test job",
389+
"steps": [
390+
{
391+
"id": "step1",
392+
"name": "Step 1",
393+
"description": "A step",
394+
"instructions_file": "steps/step1.md",
395+
"outputs": ["output.md"],
396+
"hooks": {
397+
"after_agent": [
398+
{"script": "hooks/run_tests.sh"},
399+
{"prompt_file": "hooks/evaluate.md"},
400+
],
401+
},
402+
}
403+
],
404+
}
405+
with pytest.raises(ValidationError):
406+
validate_against_schema(job_data, JOB_SCHEMA)
407+
408+
def test_valid_hooks_multiple_scripts_same_event(self) -> None:
409+
"""Test schema accepts multiple scripts in same event (no mixing)."""
410+
job_data = {
411+
"name": "test_job",
412+
"version": "1.0.0",
413+
"summary": "Test job",
414+
"steps": [
415+
{
416+
"id": "step1",
417+
"name": "Step 1",
418+
"description": "A step",
419+
"instructions_file": "steps/step1.md",
420+
"outputs": ["output.md"],
421+
"hooks": {
422+
"after_agent": [
423+
{"script": "hooks/run_tests.sh"},
424+
{"script": "hooks/run_lint.sh"},
425+
],
426+
},
427+
}
428+
],
429+
}
430+
validate_against_schema(job_data, JOB_SCHEMA)
431+
432+
def test_valid_hooks_multiple_prompts_same_event(self) -> None:
433+
"""Test schema accepts multiple prompts in same event (no mixing)."""
434+
job_data = {
435+
"name": "test_job",
436+
"version": "1.0.0",
437+
"summary": "Test job",
438+
"steps": [
439+
{
440+
"id": "step1",
441+
"name": "Step 1",
442+
"description": "A step",
443+
"instructions_file": "steps/step1.md",
444+
"outputs": ["output.md"],
445+
"hooks": {
446+
"after_agent": [
447+
{"prompt": "Check quality criteria 1"},
448+
{"prompt": "Check quality criteria 2"},
449+
],
450+
},
451+
}
452+
],
453+
}
454+
validate_against_schema(job_data, JOB_SCHEMA)
455+
358456

359457
class TestGeneratorStopHooks:
360458
"""Tests for generator stop hooks context building."""

0 commit comments

Comments
 (0)