From 13a28f5f5aee7f62dd862d812ffe61b6833c8f7a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 10 Aug 2026 15:19:25 +0000 Subject: [PATCH] feat: allow harness settings in custom agent definitions Add executor.config.harness_settings so agents can declare portable Claude Code settings.json-shaped configuration (permissions, env, etc.). - Parse harness_settings as structured YAML in bundle config - Merge agent settings into claude-native --settings sidecar with Omnigent framework keys (hooks, statusLine, apiKeyHelper) winning - Thread harness_settings to claude-sdk via HARNESS_CLAUDE_SDK_SETTINGS_OVERLAY - Add unit tests for parsing, merge semantics, and spawn-env threading Co-authored-by: jan21deepak --- omnigent/claude_native_bridge.py | 14 ++- omnigent/harness_agent_settings.py | 63 ++++++++++++ omnigent/inner/claude_sdk_executor.py | 13 ++- omnigent/inner/claude_sdk_harness.py | 35 +++++++ omnigent/runner/native/orchestration.py | 6 ++ omnigent/runtime/workflow.py | 5 + omnigent/spec/parser.py | 2 +- tests/inner/test_claude_sdk_harness.py | 21 ++++ tests/runtime/test_claude_sdk_spawn_env.py | 14 +++ tests/test_harness_agent_settings.py | 113 +++++++++++++++++++++ 10 files changed, 281 insertions(+), 5 deletions(-) create mode 100644 omnigent/harness_agent_settings.py create mode 100644 tests/test_harness_agent_settings.py diff --git a/omnigent/claude_native_bridge.py b/omnigent/claude_native_bridge.py index 1ec7d81601..f1258bebbe 100644 --- a/omnigent/claude_native_bridge.py +++ b/omnigent/claude_native_bridge.py @@ -43,7 +43,7 @@ import threading import time import urllib.parse -from collections.abc import Awaitable, Callable +from collections.abc import Awaitable, Callable, Mapping from dataclasses import dataclass from datetime import datetime from http import HTTPStatus @@ -62,6 +62,7 @@ from omnigent.llms.context_window import ModelPricing +from omnigent.harness_agent_settings import merge_claude_settings from omnigent.inner.bundle_skills import claude_native_skill_args from omnigent.inner.datamodel import OSEnvSandboxSpec, OSEnvSpec from omnigent.inner.os_env import OSEnvironment, create_os_environment @@ -1177,6 +1178,7 @@ def build_hook_settings( launch_model: str | None = None, launch_permission_mode: str | None = None, launch_effort: str | None = None, + agent_settings: Mapping[str, object] | None = None, ) -> _JsonObject: """ Build invocation-local Claude Code hook settings. @@ -1205,6 +1207,9 @@ def build_hook_settings( for the same re-exec hardening. :param launch_effort: Effective launch effort from ``--effort``. Mirrored into ``effortLevel`` for restart/re-exec parity. + :param agent_settings: Optional agent-declared Claude + ``settings.json`` fragment from + ``executor.config.harness_settings``. :returns: JSON-serializable Claude settings fragment. """ python = python_executable or sys.executable @@ -1413,6 +1418,8 @@ def build_hook_settings( if chain_command is not None: status_parts.extend(["--chain", chain_command]) settings["statusLine"] = {"type": "command", "command": shlex.join(status_parts)} + if agent_settings: + return merge_claude_settings(agent_settings, settings) return settings @@ -1453,6 +1460,7 @@ def augment_claude_args( skills_filter: str | list[str] = "all", append_system_prompt: str | None = None, allowed_tools: tuple[str, ...] = (), + agent_settings: Mapping[str, object] | None = None, ) -> list[str]: """ Return Claude CLI args with Omnigent MCP/hook/skill injection. @@ -1489,6 +1497,9 @@ def augment_claude_args( append through Claude Code's native ``--append-system-prompt`` flag. :param allowed_tools: Optional narrowly scoped Claude tool names to merge into ``--allowedTools`` without replacing the user's allowlist. + :param agent_settings: Optional agent-declared Claude + ``settings.json`` fragment from + ``executor.config.harness_settings``. :returns: Augmented argument list for the terminal resource. """ mcp_config = build_mcp_config(bridge_dir, python_executable=python_executable) @@ -1501,6 +1512,7 @@ def augment_claude_args( launch_model=_arg_value(claude_args, "--model"), launch_permission_mode=_arg_value(claude_args, "--permission-mode"), launch_effort=_arg_value(claude_args, "--effort"), + agent_settings=agent_settings, ) args = _merge_disallowed_tools(list(claude_args), _OMNIGENT_DISALLOWED_TOOLS) args = _merge_allowed_tools(args, allowed_tools) diff --git a/omnigent/harness_agent_settings.py b/omnigent/harness_agent_settings.py new file mode 100644 index 0000000000..e6a71bbc89 --- /dev/null +++ b/omnigent/harness_agent_settings.py @@ -0,0 +1,63 @@ +"""Agent-declared harness settings extracted from :class:`AgentSpec`.""" + +from __future__ import annotations + +import json +from collections.abc import Mapping +from typing import TypeAlias, cast + +from omnigent.spec.types import AgentSpec + +_Settings: TypeAlias = dict[str, object] + +# Claude Code ``settings.json`` keys owned by Omnigent at launch time. +CLAUDE_FRAMEWORK_OWNED_KEYS: frozenset[str] = frozenset({"hooks", "statusLine", "apiKeyHelper"}) + + +def harness_settings_from_spec(spec: AgentSpec) -> _Settings | None: + """Return agent-declared harness settings from ``executor.config``.""" + raw = spec.executor.config.get("harness_settings") + if not isinstance(raw, dict): + return None + return cast(_Settings, raw) + + +def serialize_harness_settings(settings: Mapping[str, object]) -> str: + """JSON-encode harness settings for spawn-env threading.""" + return json.dumps(settings, separators=(",", ":")) + + +def _deep_merge_settings(base: Mapping[str, object], overlay: Mapping[str, object]) -> _Settings: + merged = dict(base) + for key, value in overlay.items(): + existing = merged.get(key) + if isinstance(existing, dict) and isinstance(value, dict): + merged[key] = _deep_merge_settings(existing, value) + else: + merged[key] = value + return merged + + +def merge_claude_settings( + agent_settings: Mapping[str, object] | None, + framework_settings: Mapping[str, object], +) -> _Settings: + """ + Merge agent-declared Claude settings under Omnigent framework keys. + + Agent settings provide portable defaults; framework-owned keys + (``hooks``, ``statusLine``, ``apiKeyHelper``) and other framework + values win on conflict. + """ + merged: _Settings = dict(agent_settings) if agent_settings else {} + for key, value in framework_settings.items(): + if key in CLAUDE_FRAMEWORK_OWNED_KEYS: + merged[key] = value + elif isinstance(merged.get(key), dict) and isinstance(value, dict): + merged[key] = _deep_merge_settings( + cast(Mapping[str, object], merged[key]), + value, + ) + else: + merged[key] = value + return merged diff --git a/omnigent/inner/claude_sdk_executor.py b/omnigent/inner/claude_sdk_executor.py index a75a2b0e0a..de9cab714e 100644 --- a/omnigent/inner/claude_sdk_executor.py +++ b/omnigent/inner/claude_sdk_executor.py @@ -1366,6 +1366,7 @@ def __init__( agent_name: str | None = None, skills_filter: str | list[str] = "all", api_key_helper: str | None = None, + settings_overlay: dict[str, object] | None = None, ) -> None: """Create a ClaudeSDKExecutor. @@ -1439,6 +1440,8 @@ def __init__( Injected into ``_extra_env`` as :data:`_CLAUDE_API_KEY_HELPER_ENV_KEY` so it reaches the SDK's ``settings.apiKeyHelper`` option at turn time. + settings_overlay: Agent-declared Claude ``settings.json`` + fragment from ``executor.config.harness_settings``. """ # Fail loud: a ``databricks-*`` model requires the gateway transport. if not gateway and model is not None and model.startswith("databricks-"): @@ -1465,6 +1468,7 @@ def __init__( self._bundle_dir = bundle_dir self._agent_name = agent_name self._skills_filter = skills_filter + self._settings_overlay = settings_overlay # Write the bundle's plugin manifest now (idempotent) so that # ``--plugin-dir `` produces clean # ``:`` labels in Claude's skill @@ -2196,10 +2200,13 @@ async def run_turn( # ``""`` here would still leave an empty key in the child env. env = dict(self._extra_env) api_key_helper = env.pop(_CLAUDE_API_KEY_HELPER_ENV_KEY, None) + settings_dict: dict[str, object] = {} + if self._settings_overlay: + settings_dict.update(self._settings_overlay) + if api_key_helper: + settings_dict["apiKeyHelper"] = api_key_helper settings_payload = ( - json.dumps({"apiKeyHelper": api_key_helper}, separators=(",", ":")) - if api_key_helper - else None + json.dumps(settings_dict, separators=(",", ":")) if settings_dict else None ) # Capture stderr from the CLI subprocess for diagnostics diff --git a/omnigent/inner/claude_sdk_harness.py b/omnigent/inner/claude_sdk_harness.py index 5468449097..606f5cdc76 100644 --- a/omnigent/inner/claude_sdk_harness.py +++ b/omnigent/inner/claude_sdk_harness.py @@ -81,6 +81,10 @@ stable plugin name (so bundled skills show as ``:`` rather than the bundle's tmpdir basename). +- ``HARNESS_CLAUDE_SDK_SETTINGS_OVERLAY``: JSON-encoded Claude + ``settings.json`` fragment from ``executor.config.harness_settings``. + Merged into the SDK ``settings`` option at turn time; Omnigent's + ``apiKeyHelper`` wins when both are present. """ from __future__ import annotations @@ -124,6 +128,7 @@ # executor strips ANTHROPIC_API_KEY before connecting to avoid subscription # auth being bypassed). _ENV_API_KEY_HELPER = "HARNESS_CLAUDE_SDK_API_KEY_HELPER" +_ENV_SETTINGS_OVERLAY = "HARNESS_CLAUDE_SDK_SETTINGS_OVERLAY" # Default permission mode for the Claude SDK. ``"auto"`` auto-approves # tool calls with background safety checks that verify actions align @@ -250,6 +255,35 @@ def _resolve_skills_filter() -> str | list[str]: return "all" +def _resolve_settings_overlay() -> dict[str, object] | None: + """ + Resolve agent-declared Claude settings from env config. + + Reads :data:`_ENV_SETTINGS_OVERLAY` and decodes the JSON object + Omnigent serialized from ``executor.config.harness_settings``. + """ + raw = os.environ.get(_ENV_SETTINGS_OVERLAY, "").strip() + if not raw: + return None + try: + decoded = json.loads(raw) + except json.JSONDecodeError as exc: + _logger.warning( + "%s is not valid JSON (%s); ignoring agent harness settings", + _ENV_SETTINGS_OVERLAY, + exc, + ) + return None + if not isinstance(decoded, dict): + _logger.warning( + "%s decoded to unsupported shape %r; ignoring agent harness settings", + _ENV_SETTINGS_OVERLAY, + decoded, + ) + return None + return decoded + + def _build_claude_sdk_executor() -> Executor: """ Construct a :class:`ClaudeSDKExecutor` from env-var config. @@ -296,6 +330,7 @@ def _build_claude_sdk_executor() -> Executor: agent_name=agent_name, skills_filter=_resolve_skills_filter(), api_key_helper=os.environ.get(_ENV_API_KEY_HELPER) or None, + settings_overlay=_resolve_settings_overlay(), ) diff --git a/omnigent/runner/native/orchestration.py b/omnigent/runner/native/orchestration.py index 05f6754a77..cfd9b821aa 100644 --- a/omnigent/runner/native/orchestration.py +++ b/omnigent/runner/native/orchestration.py @@ -5941,6 +5941,11 @@ async def _auto_create_claude_terminal( # has the spec resolver) expose a bundle's ``skills/`` to Claude Code # via ``--plugin-dir`` — the CLI mirror of the SDK plugin wiring. # ``api_key_helper`` (ucode) registers Claude's gateway token command. + claude_agent_settings = None + if agent_spec is not None: + from omnigent.harness_agent_settings import harness_settings_from_spec + + claude_agent_settings = harness_settings_from_spec(agent_spec) claude_args = augment_claude_args( base_claude_args, bridge_dir=bridge_dir, @@ -5950,6 +5955,7 @@ async def _auto_create_claude_terminal( agent_name=agent_name, skills_filter=skills_filter, api_key_helper=claude_config.api_key_helper if claude_config is not None else None, + agent_settings=claude_agent_settings, ) # Let a registered launcher plugin (e.g. Databricks' isaac) rewrite the diff --git a/omnigent/runtime/workflow.py b/omnigent/runtime/workflow.py index 18eea73e60..624edbe80a 100644 --- a/omnigent/runtime/workflow.py +++ b/omnigent/runtime/workflow.py @@ -1242,6 +1242,11 @@ def _build_claude_sdk_spawn_env( permission_mode = spec.executor.config.get("permission_mode") if permission_mode is not None: env["HARNESS_CLAUDE_SDK_PERMISSION_MODE"] = str(permission_mode) + harness_settings = spec.executor.config.get("harness_settings") + if isinstance(harness_settings, dict) and harness_settings: + from omnigent.harness_agent_settings import serialize_harness_settings + + env["HARNESS_CLAUDE_SDK_SETTINGS_OVERLAY"] = serialize_harness_settings(harness_settings) return env diff --git a/omnigent/spec/parser.py b/omnigent/spec/parser.py index e0be6f926e..d26def0a93 100644 --- a/omnigent/spec/parser.py +++ b/omnigent/spec/parser.py @@ -91,7 +91,7 @@ class _ConfigYamlLoader(yaml.SafeLoader): # ``executor.config`` keys kept as their nested YAML structure instead of # string-coerced — their consumers read the nested mapping/list shape. -_STRUCTURED_EXECUTOR_CONFIG_KEYS: frozenset[str] = frozenset() +_STRUCTURED_EXECUTOR_CONFIG_KEYS: frozenset[str] = frozenset({"harness_settings"}) # Copy the resolver dict onto the subclass before mutating — it's inherited # from SafeLoader by reference, so in-place edits below would strip diff --git a/tests/inner/test_claude_sdk_harness.py b/tests/inner/test_claude_sdk_harness.py index 23ffad6567..a2d5b911f1 100644 --- a/tests/inner/test_claude_sdk_harness.py +++ b/tests/inner/test_claude_sdk_harness.py @@ -491,3 +491,24 @@ def _fake_init(self: Any, **kwargs: Any) -> None: assert captured["bundle_dir"] is None assert captured["agent_name"] is None + + +def test_settings_overlay_is_resolved_from_env( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv( + "HARNESS_CLAUDE_SDK_SETTINGS_OVERLAY", + '{"permissions":{"defaultMode":"acceptEdits"}}', + ) + captured: dict[str, Any] = {} + + def _fake_init(self: Any, **kwargs: Any) -> None: + captured.update(kwargs) + + with patch( + "omnigent.inner.claude_sdk_harness.ClaudeSDKExecutor.__init__", + _fake_init, + ): + claude_sdk_harness._build_claude_sdk_executor() + + assert captured["settings_overlay"] == {"permissions": {"defaultMode": "acceptEdits"}} diff --git a/tests/runtime/test_claude_sdk_spawn_env.py b/tests/runtime/test_claude_sdk_spawn_env.py index dd3d87de5a..1f053fb578 100644 --- a/tests/runtime/test_claude_sdk_spawn_env.py +++ b/tests/runtime/test_claude_sdk_spawn_env.py @@ -247,3 +247,17 @@ def test_ucode_state_with_model_is_not_overridden_by_default( env = _build_claude_sdk_spawn_env(spec, workdir=None) assert env["HARNESS_CLAUDE_SDK_MODEL"] == "databricks-claude-sonnet-4-6" + + +def test_harness_settings_are_serialized_for_claude_sdk() -> None: + spec = _make_spec() + spec.executor.config["harness_settings"] = { + "permissions": {"defaultMode": "acceptEdits"}, + "env": {"AGENT_FLAG": "1"}, + } + + env = _build_claude_sdk_spawn_env(spec, workdir=None) + + assert env["HARNESS_CLAUDE_SDK_SETTINGS_OVERLAY"] == ( + '{"permissions":{"defaultMode":"acceptEdits"},"env":{"AGENT_FLAG":"1"}}' + ) diff --git a/tests/test_harness_agent_settings.py b/tests/test_harness_agent_settings.py new file mode 100644 index 0000000000..7718a0b267 --- /dev/null +++ b/tests/test_harness_agent_settings.py @@ -0,0 +1,113 @@ +"""Tests for agent-declared harness settings.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import yaml + +from omnigent.claude_native_bridge import augment_claude_args, build_hook_settings +from omnigent.harness_agent_settings import ( + harness_settings_from_spec, + merge_claude_settings, + serialize_harness_settings, +) +from omnigent.spec.parser import parse +from omnigent.spec.types import AgentSpec, ExecutorSpec + + +def test_parse_preserves_harness_settings_structure(tmp_path: Path) -> None: + bundle = tmp_path / "agent" + bundle.mkdir() + (bundle / "config.yaml").write_text( + yaml.safe_dump( + { + "spec_version": 1, + "name": "settings-agent", + "executor": { + "type": "omnigent", + "config": { + "harness": "claude-native", + "harness_settings": { + "permissions": {"defaultMode": "acceptEdits"}, + "env": {"AGENT_FLAG": "1"}, + }, + }, + }, + } + ), + encoding="utf-8", + ) + + spec = parse(bundle) + + assert spec.executor.config["harness_settings"] == { + "permissions": {"defaultMode": "acceptEdits"}, + "env": {"AGENT_FLAG": "1"}, + } + + +def test_harness_settings_from_spec_returns_none_when_absent() -> None: + spec = AgentSpec(spec_version=1, name="bare", executor=ExecutorSpec()) + + assert harness_settings_from_spec(spec) is None + + +def test_merge_claude_settings_preserves_framework_hooks() -> None: + agent = { + "hooks": {"Stop": [{"hooks": [{"type": "command", "command": "agent-hook"}]}]}, + "permissions": {"defaultMode": "acceptEdits"}, + "env": {"AGENT_FLAG": "1"}, + } + framework = { + "hooks": {"Stop": [{"hooks": [{"type": "command", "command": "framework-hook"}]}]}, + "statusLine": {"type": "command", "command": "framework-status"}, + "permissions": {"defaultMode": "auto"}, + } + + merged = merge_claude_settings(agent, framework) + + assert merged["hooks"] == framework["hooks"] + assert merged["statusLine"] == framework["statusLine"] + assert merged["permissions"] == {"defaultMode": "auto"} + assert merged["env"] == {"AGENT_FLAG": "1"} + + +def test_build_hook_settings_merges_agent_overlay(tmp_path: Path) -> None: + settings = build_hook_settings( + tmp_path, + agent_settings={ + "permissions": {"defaultMode": "acceptEdits"}, + "env": {"AGENT_FLAG": "1"}, + }, + launch_permission_mode="auto", + ) + + assert settings["permissions"] == {"defaultMode": "auto"} + assert settings["env"] == {"AGENT_FLAG": "1"} + assert "hooks" in settings + assert "statusLine" in settings + + +def test_augment_claude_args_threads_agent_settings(tmp_path: Path) -> None: + args = augment_claude_args( + ("--permission-mode", "auto"), + bridge_dir=tmp_path, + python_executable="/venv/bin/python", + agent_settings={ + "permissions": {"defaultMode": "acceptEdits"}, + "env": {"AGENT_FLAG": "1"}, + }, + ) + + settings = json.loads(args[args.index("--settings") + 1]) + assert settings["permissions"] == {"defaultMode": "auto"} + assert settings["env"] == {"AGENT_FLAG": "1"} + assert "omnigent.claude_native_hook" in settings["hooks"]["Stop"][0]["hooks"][0]["command"] + + +def test_serialize_harness_settings_is_compact_json() -> None: + payload = serialize_harness_settings({"permissions": {"defaultMode": "plan"}}) + + assert payload == '{"permissions":{"defaultMode":"plan"}}'