simbiot-harness/venv/lib/python3.12/site-packages/strands_harness/config.py
Simbiot Harness b60439152b Simbiot Harness v0.1: тактовая лестница на Strands + CQRS/ES-спина
- harness/es.py: append-only event store + проекции (образец — кооп-портал)
- harness/levels.py: уровни 1с/1м/1ч/1д, контекст ∝ масштабу
- harness/scheduler.py: уровневые потоки, идемпотентные тики, absorbed-стабы
- harness/isolated_runner.py + _llm_worker.py: LLM-тик в сабпроцессе
  (глобальный asyncio-рантайм Strands не переживает 2+ create_harness)
- cli.py: init/task/tasks/status/events + requeue (написан самим минутным агентом)
- 9router: отдельный ключ sk-simbiot, модель auto (glm-5.3 первым)
2026-09-29 15:28:50 +00:00

636 lines
24 KiB
Python

"""Serializable harness configuration shared with TypeScript and exported projects."""
from __future__ import annotations
import importlib
import importlib.util
import json
import logging
import operator
import os
import re
import sys
from collections.abc import Callable
from copy import deepcopy
from functools import reduce
from pathlib import Path
from types import ModuleType
from typing import Annotated, Any, Literal
from pydantic import (
AfterValidator,
BaseModel,
BeforeValidator,
ConfigDict,
Field,
JsonValue,
RootModel,
SkipValidation,
TypeAdapter,
ValidationError,
model_validator,
)
from pydantic.alias_generators import to_camel
from pydantic_core import PydanticCustomError
from strands_harness import defaults
from strands_harness.types.agent import BuiltinPluginName, BuiltinToolName, Effort, WebFetchTransport
__all__ = [
"DEFAULT_HARNESS_AGENT_CONFIG",
"define_harness_agent_config",
"harness_agent_kwargs_from_config",
"normalize_harness_agent_config",
]
logger = logging.getLogger(__name__)
DEFAULT_HARNESS_AGENT_CONFIG: dict[str, Any] = {
"name": "Strands harness",
"description": "",
"instructions": "",
"model": defaults.DEFAULT_MODEL,
"modelModule": None,
"effort": defaults.DEFAULT_EFFORT,
"tools": [],
"subagents": [],
"mcpServers": {},
"builtinTools": list(defaults.DEFAULT_BUILTIN_TOOLS),
"caching": True,
"contextManager": defaults.DEFAULT_CONTEXT_MANAGER,
"session": True,
"skills": True,
"memory": True,
"memoryStores": [],
"plugins": [],
"builtinPlugins": list(defaults.DEFAULT_BUILTIN_PLUGINS),
"interventions": None,
"interventionModules": [],
"sandbox": None,
"agentConfigModules": {},
"dependencies": {"typescript": {}, "python": []},
"agentConfig": {},
}
def define_harness_agent_config(config: dict[str, Any] | None = None) -> dict[str, Any]:
"""Fill a partial portable config with the harness defaults."""
return normalize_harness_agent_config({**deepcopy(DEFAULT_HARNESS_AGENT_CONFIG), **(config or {})})
def normalize_harness_agent_config(value: object) -> dict[str, Any]:
"""Validate and normalize a JSON-compatible harness definition.
Normalizing also folds JSON-only spellings into their kwargs form: ``contextManager: "off"`` is ``False``.
"""
if not isinstance(value, dict):
raise ValueError("agent config must be an object.")
unknown = sorted(set(value) - set(DEFAULT_HARNESS_AGENT_CONFIG))
if unknown:
logger.warning("Ignoring unknown agent config keys: %s.", ", ".join(unknown))
config = {
**DEFAULT_HARNESS_AGENT_CONFIG,
**{key: item for key, item in value.items() if key in DEFAULT_HARNESS_AGENT_CONFIG},
}
try:
parsed = _HarnessAgentConfig.model_validate(config)
except ValidationError as error:
raise ValueError(_describe(error, config)) from error
# Every top-level key is set by the merge above; nested objects keep only the keys the config spelled out.
return parsed.model_dump(by_alias=True, exclude_unset=True)
def _describe(error: ValidationError, config: dict[str, Any]) -> str:
"""One line per problem, located by its camelCase config path (``builtinTools.shell.description``).
Each location is walked against ``config`` so only real keys and indexes survive: pydantic tags
``JsonValue`` union members into the path (``agentConfig.x.float``), and those tags are dropped. The
one segment that is legitimately absent from the input is the name of a missing required key.
"""
lines = []
for item in error.errors(include_url=False):
path: list[str] = []
node: Any = config
for part in item["loc"]:
if (isinstance(node, dict) and part in node) or (
isinstance(node, list) and isinstance(part, int) and 0 <= part < len(node)
):
node = node[part]
elif item["type"] != "missing":
continue
path.append(str(part))
lines.append(f"{'.'.join(path)}: {item['msg']}" if path else item["msg"])
return "\n".join(lines)
def harness_agent_kwargs_from_config(value: object, base_dir: str | Path = ".") -> dict[str, Any]:
"""Load executable references and convert portable config to ``create_harness`` kwargs.
Local multi-file modules must use regular ``__init__.py`` packages and explicit relative imports.
Bare imports use Python's normal import path; project directories are not added to ``sys.path``.
"""
config = normalize_harness_agent_config(value)
root = Path(base_dir).resolve()
tools = _load_many(config["tools"], root)
subagents = _load_many(config["subagents"], root, invoke=True)
plugins = _load_many(config["plugins"], root)
stores = _load_many(config["memoryStores"], root)
sandbox = _load_optional(config["sandbox"], root)
model = _load_optional(config["modelModule"], root)
interventions = _load_many(config["interventionModules"], root)
agent_config_modules = {
key: _load_reference(reference, root) for key, reference in config["agentConfigModules"].items()
}
kwargs = deepcopy(config["agentConfig"])
kwargs.update(agent_config_modules)
kwargs.update(
{
"name": config["name"],
"model": config["model"] if model is None else model,
"effort": config["effort"],
"context_manager": config["contextManager"],
"session": _session_kwarg(config["session"], root),
"skills": _skills_kwarg(config["skills"], root),
"memory": _memory_kwarg(config["memory"], stores, root),
}
)
builtin_tools = _builtin_tools_kwarg(config["builtinTools"], root)
if builtin_tools is not None:
kwargs["builtin_tools"] = builtin_tools
if not config["caching"]:
kwargs["caching"] = False
if config["builtinPlugins"] != list(defaults.DEFAULT_BUILTIN_PLUGINS):
kwargs["builtin_plugins"] = config["builtinPlugins"]
if config["description"]:
kwargs["description"] = config["description"]
if config["instructions"]:
kwargs["instructions"] = config["instructions"]
# Specialist agents declared as ``subagents`` module refs are wired like any tool: each is exposed
# via ``Agent.as_tool()`` and appended to ``tools`` (the harness has no separate subagents param).
tools = [*tools, *(agent.as_tool() for agent in subagents)]
if tools:
kwargs["tools"] = tools
if config["mcpServers"]:
kwargs["mcp_servers"] = _python_mcp_servers(
_expand_env(_resolve_mcp_working_directories(_mcp_server_map(config["mcpServers"], root), root))
)
if plugins:
kwargs["plugins"] = plugins
configured_interventions = (
config["interventions"]
if isinstance(config["interventions"], list)
else [config["interventions"]]
if config["interventions"]
else []
)
intervention_values = [
_resolve_path(value.strip(), root) if value.strip().endswith(".cedar") else value
for value in configured_interventions
]
intervention_values.extend(interventions)
if intervention_values:
kwargs["interventions"] = intervention_values[0] if len(intervention_values) == 1 else intervention_values
if sandbox is not None:
kwargs["sandbox"] = sandbox
return kwargs
def _one_of(message: str, *branches: tuple[type | Callable[[object], bool], Any]) -> Any:
"""Union of ``branches``, picked by input shape (a type or a predicate); no match raises ``message``.
Hand-rolled because pydantic's unions report a failed value once per member and tag error paths with the
member's name (``session.SessionConfig.id``); a callable ``Discriminator`` still leaks its tag and warns on
dump. ``SkipValidation`` on the union itself keeps the matched branch validated exactly once.
"""
adapters = [(shape, TypeAdapter(annotation)) for shape, annotation in branches]
def validate(value: object) -> object:
for shape, adapter in adapters:
if isinstance(value, shape) if isinstance(shape, type) else shape(value):
return adapter.validate_python(value, strict=True)
raise PydanticCustomError("shape", message)
union = reduce(operator.or_, (annotation for _, annotation in branches))
return Annotated[union, SkipValidation, BeforeValidator(validate)]
def _non_blank_str(message: str = "Input should be a non-empty string") -> Any:
def validate(value: str) -> str:
if not value.strip():
raise PydanticCustomError("string_blank", message)
return value
return Annotated[str, AfterValidator(validate)]
def _record(value: Any) -> Any:
"""``dict[str, value]`` whose keys are non-blank, reported against the record rather than the key."""
def check_keys(record: object) -> object:
if isinstance(record, dict):
for key in record:
if not isinstance(key, str) or not key.strip():
raise PydanticCustomError(
"dict_key_blank", "Object keys should be non-empty strings, got {key}", {"key": repr(key)}
)
return record
return Annotated[dict[str, value], BeforeValidator(check_keys)]
def _single_line(value: str) -> str:
if "\n" in value or "\r" in value:
raise PydanticCustomError("string_multiline", "Input should not contain line breaks")
return value
def _off_is_false(value: str) -> str | bool:
return False if value == "off" else value
def _unique(items: list[Any]) -> list[Any]:
if len(set(items)) != len(items):
raise PydanticCustomError("unique_list", "List items should be unique")
return items
def _positive_number(value: object) -> object:
if value is not None and (isinstance(value, bool) or not isinstance(value, (int, float)) or value <= 0):
raise PydanticCustomError("positive_number", "Input should be a positive number or null")
return value
NonBlankStr = _non_blank_str()
_UNIQUE = AfterValidator(_unique)
_UNSET: Any = None
"""Unset marker for optional keys that must not be explicit null; dumps use ``exclude_unset``."""
class _Model(BaseModel):
"""JSON object in ``config.json``: camelCase keys, no unknown keys, no type coercion.
Optional keys default to ``_UNSET`` (dumps use ``exclude_unset``); only fields typed ``| None`` accept an
explicit null.
"""
model_config = ConfigDict(alias_generator=to_camel, extra="forbid", strict=True, allow_inf_nan=False)
class _JsonObject(RootModel[dict[str, JsonValue]]):
model_config = ConfigDict(strict=True, allow_inf_nan=False)
class _ModuleRef(_Model):
"""``{kind, module, export?, language?, files?, dependency?}`` pointing at an importable value.
``kind`` is filled in by ``_reference`` from the field holding the reference; other keys pass through unchanged.
"""
model_config = ConfigDict(alias_generator=to_camel, extra="allow", strict=True, allow_inf_nan=False)
__pydantic_extra__: dict[str, JsonValue]
kind: str | None = None
module: NonBlankStr
export: NonBlankStr = _UNSET
language: Literal["python", "typescript"] = _UNSET
files: list[NonBlankStr] = _UNSET
dependency: NonBlankStr = _UNSET
def _reference(kind: str) -> Any:
"""A ``_ModuleRef`` object whose ``kind`` defaults to, and must be, ``kind``."""
def expect_kind(reference: _ModuleRef) -> _ModuleRef:
if "kind" not in reference.model_fields_set:
reference.kind = kind
elif reference.kind != kind:
raise PydanticCustomError("kind", f"Input should be '{kind}'")
return reference
return _one_of(
"Input should be a module reference object", (dict, Annotated[_ModuleRef, AfterValidator(expect_kind)])
)
_ModelReference = _reference("model")
class _ReadConfig(_Model):
media: bool = _UNSET
class _ShellConfig(_Model):
description: NonBlankStr = _UNSET
class _WebFetchConfig(_Model):
model: _one_of(
"Input should be a non-empty string or a model module reference", (str, NonBlankStr), (dict, _ModelReference)
) = _UNSET
transport: WebFetchTransport = _UNSET
class _ProgrammaticToolCallerConfig(_Model):
allowed_tools: list[NonBlankStr] | None = None
timeout: Annotated[int | float | None, BeforeValidator(_positive_number)] = None
class _SubagentConfig(_Model):
max_depth: Annotated[int, Field(ge=0)] = _UNSET
_BUILTIN_TOOL_CONFIGS: dict[str, type[_Model]] = {
"read": _ReadConfig,
"shell": _ShellConfig,
"web_fetch": _WebFetchConfig,
"programmatic_tool_caller": _ProgrammaticToolCallerConfig,
"subagent": _SubagentConfig,
}
"""Built-ins that take a config object as their ``builtinTools`` value; every other built-in is bool-only."""
def _bool_or(config: type[_Model]) -> Any:
keys = ", ".join(field.alias or name for name, field in config.model_fields.items())
return _one_of(f"Input should be a boolean or an object with optional {keys}", (bool, bool), (dict, config))
class _BuiltinToolsMap(BaseModel):
"""``builtinTools`` as edits to the default set: ``"*"`` picks the starting set, each tool a bool or config."""
model_config = ConfigDict(extra="forbid", strict=True)
all_tools: bool = Field(default=_UNSET, alias="*")
shell: _bool_or(_ShellConfig) = _UNSET
read: bool = _UNSET
write: bool = _UNSET
edit: bool = _UNSET
web_fetch: _bool_or(_WebFetchConfig) = _UNSET
web_search: _one_of(
"Input should be a boolean or 'exa'", (bool, bool), (lambda value: value == "exa", Literal["exa"])
) = _UNSET
programmatic_tool_caller: _bool_or(_ProgrammaticToolCallerConfig) = _UNSET
subagent: _bool_or(_SubagentConfig) = _UNSET
class _SessionConfig(_Model):
id: NonBlankStr = _UNSET
dir: NonBlankStr = _UNSET
class _MemoryConfig(_Model):
dir: NonBlankStr = _UNSET
class _Dependencies(BaseModel):
"""``{typescript: {package: version}, python: [requirement]}``; a missing half defaults to empty."""
model_config = ConfigDict(strict=True)
typescript: _record(NonBlankStr)
python: Annotated[list[Annotated[NonBlankStr, AfterValidator(_single_line)]], _UNIQUE]
@model_validator(mode="before")
@classmethod
def _fill_halves(cls, value: object) -> object:
return {"typescript": {}, "python": [], **value} if isinstance(value, dict) else value
_MCP_SERVERS_SHAPE = "Input should be an object or a non-empty file path"
class _HarnessAgentConfig(_Model):
"""``DEFAULT_HARNESS_AGENT_CONFIG`` as a schema; every key is required because defaults are merged first."""
name: NonBlankStr
description: str
instructions: str
model: NonBlankStr
model_module: _ModelReference | None
effort: Effort
tools: list[_reference("tool")]
subagents: list[_reference("subagent")]
mcp_servers: _one_of(_MCP_SERVERS_SHAPE, (dict, _JsonObject), (str, _non_blank_str(_MCP_SERVERS_SHAPE)))
builtin_tools: _one_of(
"Input should be an array of tool names or an object of per-tool settings",
(list, Annotated[list[BuiltinToolName], _UNIQUE]),
(dict, _BuiltinToolsMap),
)
caching: bool
context_manager: _one_of(
"Input should be one of 'auto', 'agentic', 'off'; false; or a config object",
(str, Annotated[Literal["auto", "agentic", "off"], AfterValidator(_off_is_false)]),
(lambda value: value is False, Literal[False]),
(dict, _JsonObject),
)
session: _one_of(
"Input should be a boolean or an object with optional id and dir", (bool, bool), (dict, _SessionConfig)
)
skills: _one_of(
"Input should be a boolean, a path, or an array of paths",
(bool, bool),
(str, NonBlankStr),
(list, Annotated[list[NonBlankStr], _UNIQUE]),
)
memory: _one_of("Input should be a boolean or an object with an optional dir", (bool, bool), (dict, _MemoryConfig))
memory_stores: list[_reference("memory-store")]
plugins: list[_reference("plugin")]
builtin_plugins: Annotated[list[BuiltinPluginName], _UNIQUE]
interventions: _one_of(
"Input should be a string, an array of strings, or null",
(str, NonBlankStr),
(list, list[NonBlankStr]),
(type(None), None),
)
intervention_modules: list[_reference("intervention")]
sandbox: _reference("sandbox") | None
agent_config_modules: _record(_reference("agent-config"))
dependencies: _Dependencies
agent_config: _JsonObject
def _builtin_tools_kwarg(value: list[str] | dict[str, Any], root: Path) -> list[str] | dict[str, Any] | None:
if isinstance(value, list):
# The portable default spells out today's set; leave it to the factory so the mapping stays "defaults".
return None if value == list(defaults.DEFAULT_BUILTIN_TOOLS) else list(value)
resolved: dict[str, Any] = {}
for name, setting in value.items():
if isinstance(setting, dict):
fields = {field.alias or key: key for key, field in _BUILTIN_TOOL_CONFIGS[name].model_fields.items()}
setting = {fields[key]: item for key, item in setting.items()}
if isinstance(setting.get("model"), dict):
setting["model"] = _load_reference(setting["model"], root)
resolved[name] = setting
return resolved
def _session_kwarg(value: bool | dict[str, Any], root: Path) -> bool | dict[str, Any]:
if isinstance(value, bool):
return value
session = dict(value)
if "dir" in session:
session["dir"] = _resolve_path(session["dir"], root)
return session
def _skills_kwarg(value: bool | str | list[str], root: Path) -> bool | str | list[str]:
if isinstance(value, bool):
return value
if isinstance(value, str):
return _resolve_skill_source(value, root)
return [_resolve_skill_source(item, root) for item in value]
def _resolve_skill_source(value: str, root: Path) -> str:
return value if value.startswith(("http://", "https://")) else _resolve_path(value, root)
def _memory_kwarg(value: bool | dict[str, Any], stores: list[Any], root: Path) -> bool | dict[str, Any]:
if value is False:
return False
memory: dict[str, Any] = {} if value is True else dict(value)
if "dir" in memory:
memory["dir"] = _resolve_path(memory["dir"], root)
if stores:
memory["stores"] = stores
return memory or True
def _load_many(references: list[dict[str, Any]], root: Path, *, invoke: bool = False) -> list[Any]:
values: list[Any] = []
for reference in references:
value = _load_reference(reference, root)
if invoke and callable(value):
value = value()
values.extend(value if isinstance(value, list) else [value])
return values
def _load_optional(reference: dict[str, Any] | None, root: Path) -> Any:
return None if reference is None else _load_reference(reference, root)
def _load_reference(reference: dict[str, Any], root: Path) -> Any:
if reference.get("language") == "typescript" or Path(reference["module"]).suffix in {
".js",
".jsx",
".mjs",
".cjs",
".ts",
".tsx",
}:
raise ValueError(f"Cannot load TypeScript module {reference['module']!r} in the Python runtime.")
module_name = reference["module"]
module = _load_module(module_name, root)
export = reference.get("export", "default")
if export == "default" and not hasattr(module, "default"):
export = "agent" if reference.get("kind") == "subagent" and hasattr(module, "agent") else export
if not hasattr(module, export):
raise ValueError(f"Module {module_name!r} does not export {export!r}.")
return getattr(module, export)
def _load_module(module: str, root: Path) -> ModuleType:
if module.startswith(".") or os.path.isabs(module):
path = Path(_resolve_path(module, root)).resolve()
if path.is_dir():
path /= "__init__.py"
package = path.parent.parent if path.name == "__init__.py" else path.parent
if (package / "__init__.py").is_file():
parent = _load_module(str(package / "__init__.py"), root)
name = path.parent.name if path.name == "__init__.py" else path.stem
return importlib.import_module(f"{parent.__name__}.{name}")
# Private package names isolate projects without shadowing installed packages.
name = f"_strands_project_{abs(hash(path))}"
if name in sys.modules:
return sys.modules[name]
spec = importlib.util.spec_from_file_location(name, path)
if spec is None or spec.loader is None:
raise ValueError(f"Cannot load Python module {module!r}.")
loaded = importlib.util.module_from_spec(spec)
sys.modules[name] = loaded
try:
spec.loader.exec_module(loaded)
except BaseException:
sys.modules.pop(name, None)
raise
return loaded
return importlib.import_module(module)
def _resolve_path(value: str, root: Path) -> str:
path = Path(value).expanduser()
return str(path if path.is_absolute() else root / path)
def _expand_env(value: Any) -> Any:
if isinstance(value, str):
def replace(match: re.Match[str]) -> str:
name = match.group(1)
resolved = os.environ.get(name)
if resolved is None:
raise ValueError(f"Environment variable {json.dumps(name)} is not set.")
return resolved
return re.sub(
r"\$\{(?:env:)?([A-Za-z_][A-Za-z0-9_]*)\}",
replace,
value,
)
if isinstance(value, list):
return [_expand_env(item) for item in value]
if isinstance(value, dict):
return {key: _expand_env(item) for key, item in value.items()}
return value
def _python_mcp_servers(servers: dict[str, Any]) -> dict[str, Any]:
renamed = {
"continueOnError": "continue_on_error",
"toolFilters": "tool_filters",
"clientId": "client_id",
"clientSecret": "client_secret",
}
def convert(value: Any) -> Any:
if isinstance(value, list):
return [convert(item) for item in value]
if isinstance(value, dict):
if "tasksConfig" in value:
raise ValueError("The Python harness does not support MCP tasksConfig.")
return {renamed.get(key, key): convert(item) for key, item in value.items()}
return value
return convert(servers)
def _resolve_mcp_working_directories(servers: dict[str, Any], root: Path) -> dict[str, Any]:
resolved: dict[str, Any] = {}
for name, server in servers.items():
if not isinstance(server, dict) or not isinstance(server.get("command"), str):
resolved[name] = server
continue
configured = server.get("cwd")
resolved[name] = {
**server,
"cwd": _resolve_path(configured, root) if isinstance(configured, str) else str(root),
}
return resolved
def _mcp_server_map(value: dict[str, Any] | str, root: Path) -> dict[str, Any]:
if isinstance(value, str):
path = Path(_resolve_path(value, root))
try:
loaded = json.loads(path.read_text())
except (OSError, json.JSONDecodeError) as error:
raise ValueError(f"MCP config {value!r} must be valid JSON.") from error
if not isinstance(loaded, dict):
raise ValueError(f"MCP config {value!r} must contain a server map.")
value = loaded
nested = value.get("mcpServers")
if nested is None:
return value
if not isinstance(nested, dict):
raise ValueError("mcpServers must contain a server map.")
return nested