···11+"""Manifest data types and provider-neutral tool projection helpers.
22+33+The tools the model sees and the capabilities the broker enforces are derived
44+from the same source: `build_manifest` projects every non-deny capability into a
55+tool.
66+"""
77+88+from typing import Any
99+1010+from dataclasses import dataclass, field
1111+1212+1313+@dataclass(frozen=True)
1414+class Grant:
1515+ """The host reach a capability opens. Empty means "nothing beyond the shell"."""
1616+1717+ package_bins: list[str] = field(default_factory=list)
1818+ allowed_hosts: list[str] = field(default_factory=list)
1919+ writable: list[str] = field(default_factory=list)
2020+ unrestricted: bool = False
2121+ # The store path of the `closureInfo` `store-paths` file for this grant's
2222+ # packages (emitted by Nix). `closure_paths` is its realized contents — the
2323+ # exact store paths the jail binds, so the capability reaches its declared
2424+ # closure and nothing else. Populated after realization (manifest_loader).
2525+ closure_file: str = ""
2626+ closure_paths: list[str] = field(default_factory=list)
2727+2828+2929+@dataclass(frozen=True)
3030+class Param:
3131+ type: str # JSON Schema scalar: "string" | "integer" | "boolean" | "array"
3232+ description: str
3333+ required: bool = False
3434+ enum: list[Any] | None = None
3535+3636+3737+@dataclass(frozen=True)
3838+class Capability:
3939+ name: str
4040+ description: str
4141+ policy: str # "auto" | "ask-once" | "ask-always" | "deny"
4242+ params: dict[str, Param]
4343+ grants: Grant
4444+ runner: str
4545+ # Per-capability wall-clock budget in seconds. None means the capability runs
4646+ # unbounded; a declared value caps it at that many seconds.
4747+ timeout: int | None = None
4848+ # How the broker runs this capability (PLAN.md §6.5):
4949+ # "command" — run in the jail, capture output, return one result (default).
5050+ # "background" — launch detached, return a handle; track in the registry.
5151+ # "control" — operate on the background registry (see `control`); no jail.
5252+ kind: str = "command"
5353+ # For kind == "control": which registry operation this tool performs,
5454+ # one of "status" | "output" | "stop". None for every other kind.
5555+ control: str | None = None
5656+5757+5858+Sampling = dict[str, int | float]
5959+6060+6161+@dataclass(frozen=True)
6262+class ModelConfig:
6363+ """The model an agent declares: backend binding + inference knobs (PLAN.md §9).
6464+6565+ A model id is only meaningful next to the base_url that serves it, so they
6666+ travel together alongside provider-portable inference knobs. Secrets and
6767+ deployment-specific headers are sourced from the environment, never from the
6868+ Nix store.
6969+ """
7070+7171+ base_url: str | None = None
7272+ name: str | None = None
7373+ provider: str | None = None # provider type, e.g. "openai-compat"
7474+ max_tokens: int | None = None
7575+ sampling: Sampling | None = None
7676+7777+7878+@dataclass(frozen=True)
7979+class Manifest:
8080+ tools: list[dict] # provider-neutral tool defs (name/description/parameters)
8181+ capabilities: dict[str, Capability]
8282+ # The agent's persona, declared in Nix. None when the agent declares none.
8383+ system_prompt: str | None = None
8484+ # The agent's model block, declared in Nix. None when the agent declares none,
8585+ # in which case the harness defaults (or env overrides) supply everything.
8686+ model: ModelConfig | None = None
8787+ # The CA bundle path emitted by the agent flake. Exported into jailed tools
8888+ # (base_env), and the Nix shell closure binds its store root.
8989+ ca_bundle_file: str = ""
9090+ # The baked baseline PATH for the jail (colon-joined `…/bin` store dirs),
9191+ # emitted by Nix from the declared shell packages. Replaces a `nix develop`
9292+ # resolution, so PATH always equals the bound shell closure.
9393+ shell_path: str = ""
9494+ # The baseline closure every jailed call binds (shell PATH packages + the CA
9595+ # bundle), as the `store-paths` file path emitted by Nix and its realized
9696+ # contents. `shell_closure` is filled after realization (manifest_loader).
9797+ shell_closure_file: str = ""
9898+ shell_closure: list[str] = field(default_factory=list)
9999+100100+101101+def _params_to_json_schema(params: dict[str, Param]) -> dict[str, Any]:
102102+ """Project typed params into a provider-neutral JSON Schema object."""
103103+ properties: dict[str, Any] = {}
104104+ required: list[str] = []
105105+ for name, param in params.items():
106106+ schema: dict[str, Any] = {"type": param.type, "description": param.description}
107107+ if param.enum is not None:
108108+ schema["enum"] = param.enum
109109+ properties[name] = schema
110110+ if param.required:
111111+ required.append(name)
112112+ return {"type": "object", "properties": properties, "required": required}
113113+114114+115115+def tool_from_capability(capability: Capability) -> dict:
116116+ """The model-facing tool projection of a capability."""
117117+ return {
118118+ "name": capability.name,
119119+ "description": capability.description,
120120+ "parameters": _params_to_json_schema(capability.params),
121121+ }
122122+123123+124124+def build_manifest(capabilities: dict[str, Capability]) -> Manifest:
125125+ """Build a Manifest, exposing every non-deny capability as a tool."""
126126+ tools = [
127127+ tool_from_capability(capability)
128128+ for capability in capabilities.values()
129129+ if capability.policy != "deny"
130130+ ]
131131+ return Manifest(tools=tools, capabilities=capabilities)