diff --git a/README.md b/README.md index 312d9e2..40386d9 100644 --- a/README.md +++ b/README.md @@ -146,3 +146,11 @@ Mocked unit tests are not evidence of a successful real sandbox run. Licensed under the Apache License, Version 2.0. See `LICENSE` for the full terms. Read `SECURITY.md` before enabling the workflow. + +### Optional system prompt + +Use `system-prompt` to pass a trusted absolute path to a UTF-8 file (up to +128 KiB). Its contents are appended to the default Claude Code system prompt +for planning and execution, not video editing. Omit it to retain the default. +Keep repository-specific instructions in your own repository; the action does +not interpret them or configure your environment. Do not include credentials. diff --git a/action.yml b/action.yml index 7a2d72f..a91fc1b 100644 --- a/action.yml +++ b/action.yml @@ -48,6 +48,9 @@ inputs: pool: description: Cua pool accessible to the supplied credentials. required: true + system-prompt: + description: Optional trusted absolute path to UTF-8 text appended to planning and execution system prompts (128 KiB maximum). + default: '' planning-prompt: description: Optional trusted absolute path overriding the planning prompt. default: '' @@ -107,6 +110,7 @@ runs: CUA_BASE_URL: ${{ inputs.cua-base-url }} CUA_TOKEN_URL: ${{ inputs.cua-token-url }} E2E_POOL: ${{ inputs.pool }} + E2E_SYSTEM_PROMPT: ${{ inputs.system-prompt }} E2E_PLANNING_PROMPT: ${{ inputs.planning-prompt }} E2E_EXECUTION_PROMPT: ${{ inputs.execution-prompt }} E2E_VIDEO_EDITING_PROMPT: ${{ inputs.video-editing-prompt }} diff --git a/agent/e2e_test_agent.py b/agent/e2e_test_agent.py index 8874025..82e4bb6 100755 --- a/agent/e2e_test_agent.py +++ b/agent/e2e_test_agent.py @@ -178,6 +178,18 @@ async def guard(input_data, _tool_use_id, _context): return guard +def agent_system_prompt() -> dict[str, str]: + prompt = {"type": "preset", "preset": "claude_code"} + extra = env("E2E_SYSTEM_PROMPT") + if extra: + with Path(extra).open("rb") as source: + content = source.read(128 * 1024 + 1) + if len(content) > 128 * 1024: + raise ValueError("System prompt exceeds the 128 KiB limit") + prompt["append"] = content.decode("utf-8") + return prompt + + async def run_agent( *, prompt: str, @@ -233,7 +245,7 @@ async def run_agent( sandbox=sandbox, setting_sources=[], strict_mcp_config=bool(mcp_servers), - system_prompt={"type": "preset", "preset": "claude_code"}, + system_prompt=agent_system_prompt(), tools=tools or ["Read", "Glob", "Grep", "Write"], ) result: ResultMessage | None = None diff --git a/scripts/run_action.py b/scripts/run_action.py index 62ecd2f..03810cb 100644 --- a/scripts/run_action.py +++ b/scripts/run_action.py @@ -138,6 +138,8 @@ def command_env(self, phase): 'MCP_PROXY_PORT': '3333', }) environment.update(self.metadata) + if phase in ('plan', 'execute') and self.environment.get('E2E_SYSTEM_PROMPT'): + environment['E2E_SYSTEM_PROMPT'] = self.environment['E2E_SYSTEM_PROMPT'] if phase in ('plan', 'execute', 'edit-video'): names = ('ANTHROPIC_API_KEY', 'ANTHROPIC_BASE_URL', 'ANTHROPIC_MODEL', 'E2E_PLANNING_PROMPT', 'E2E_EXECUTION_PROMPT', 'E2E_VIDEO_EDITING_PROMPT', diff --git a/tests/test_action.py b/tests/test_action.py index 04d6898..95af7a9 100644 --- a/tests/test_action.py +++ b/tests/test_action.py @@ -31,6 +31,15 @@ def setUp(self): def runner(self): return run_action.Runner(run_action.Config(self.env), self.env) + def test_system_prompt_is_only_forwarded_to_planning_and_execution(self): + self.env['E2E_SYSTEM_PROMPT'] = '/trusted/custom-system.md' + runner = self.runner() + for phase in ('plan', 'execute'): + self.assertEqual(runner.command_env(phase)['E2E_SYSTEM_PROMPT'], + '/trusted/custom-system.md') + for phase in ('pr-context', 'claim-create', 'edit-video'): + self.assertNotIn('E2E_SYSTEM_PROMPT', runner.command_env(phase)) + def test_required_configuration_fails_before_running(self): for key in ('E2E_REPOSITORY', 'PR_NUMBER', 'HEAD_SHA', 'E2E_POOL', 'GH_TOKEN', 'CUA_CLIENT_ID', 'CUA_CLIENT_SECRET', 'ANTHROPIC_API_KEY', diff --git a/tests/test_agent.py b/tests/test_agent.py index 5230e6b..20e738a 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -46,6 +46,28 @@ class E2ETestAgentTests(unittest.TestCase): def setUp(self) -> None: self.agent = load_agent_module() + def test_custom_text_is_appended_to_system_prompt(self): + with tempfile.TemporaryDirectory() as directory: + context = Path(directory) / 'custom-system.md' + context.write_text('Three nspawn nodes and local Keycloak.') + with mock.patch.dict('os.environ', {'E2E_SYSTEM_PROMPT': str(context)}): + prompt = self.agent.agent_system_prompt() + self.assertEqual(prompt['preset'], 'claude_code') + self.assertIn('Three nspawn nodes and local Keycloak.', prompt['append']) + + def test_default_system_prompt_is_unchanged(self): + with mock.patch.dict('os.environ', {}, clear=True): + self.assertEqual(self.agent.agent_system_prompt(), + {'type': 'preset', 'preset': 'claude_code'}) + + def test_system_prompt_rejects_oversized_file(self): + with tempfile.TemporaryDirectory() as directory: + context = Path(directory) / 'large.md' + context.write_bytes(b'x' * (128 * 1024 + 1)) + with mock.patch.dict('os.environ', {'E2E_SYSTEM_PROMPT': str(context)}): + with self.assertRaises(ValueError): + self.agent.agent_system_prompt() + def test_renders_all_prompt_tokens(self) -> None: with tempfile.TemporaryDirectory() as directory: prompt = Path(directory) / "prompt.md"