Agent definition is a versioned JSON document bundling the task fields this codebase already honours (model/effort/approval/ executor/plan), plus instructions and required skills. At task creation, explicit body fields win per field over the definition; unresolved fields stay NULL and follow the worker-default chain (and rule routing v0 when model is left empty). Instructions reach the model only as a marked header prepended to the stored prompt, because the dispatch path has no developerInstructions channel; goal and coordinator tasks reject instructions. Skills are validated against the cloud/skills pack at parse time; seeding itself stays worker-global, documented as the v1 boundary. Routing policy is not part of a definition in v1 (worker-global), and unknown fields are rejected so typos cannot be silently dropped. No new tasks-table column in v1; definition identity is echoed in the creation response and via the prompt header. Tests: test_agent_def.py (11 tests).
276 lines
12 KiB
Python
276 lines
12 KiB
Python
"""Declarative agent definitions (harness alignment, v1).
|
|
|
|
Zero third-party dependencies; standard library only.
|
|
|
|
A definition is a versioned JSON document that bundles the fields a
|
|
task already understands -- model, effort, approval, executor, plan --
|
|
plus a human-written instruction block and a list of required skills.
|
|
It is the Foundry-style "agent defined declaratively, harness runs
|
|
the loop" idea, cut down to what this codebase can actually honour
|
|
today, with every boundary written down instead of implied:
|
|
|
|
* Resolution happens at task creation (``POST /tasks`` with an inline
|
|
``agent`` object). Per field, an explicit body field wins over the
|
|
definition; a field absent from both stays NULL and follows the
|
|
existing worker-default chain. A definition that sets ``model``
|
|
therefore makes the task explicit for rule routing v0, and a
|
|
definition that leaves ``model`` empty leaves routing to the worker.
|
|
* Instructions reach the model only as a marked header prepended to
|
|
the stored prompt (``[Agent: <name>]``), because the dispatch path
|
|
(``cloud/sandbox.py`` -> ``host.py dispatch``) has no
|
|
developerInstructions channel today. This is NOT claimed to be
|
|
``developerInstructions``: it is visible in the stored prompt, in
|
|
the creation response, and in every later prompt read. Instructions
|
|
are rejected with goal tasks and coordinator tasks, where the
|
|
prompt is a goal or is never run as a model turn, so the header
|
|
cannot silently pollute a goal text or a coordinator parent.
|
|
* Skills are validated against the ``cloud/skills/`` pack at parse
|
|
time (an unknown skill is a 400, never a silent drop). Seeding
|
|
itself stays worker-global (``--seed-skills`` seeds the whole pack
|
|
into the tenant codex-home); v1 does NOT claim per-agent seeding.
|
|
When seeding is off, a listed skill is recorded in the creation
|
|
response but is not guaranteed present at run time -- that is the
|
|
documented v1 boundary, not a bug to be discovered later.
|
|
* Routing policy is deliberately NOT part of a definition in v1:
|
|
routing stays a worker-global policy (see ``cloud/models.py``).
|
|
Putting a routing object inside a definition would either be
|
|
silently dropped (a lie) or need a per-task policy column that does
|
|
not exist. A definition that contains ``routing`` / ``routing_policy``
|
|
is rejected, so a typo or a v2 idea cannot masquerade as v1 behaviour.
|
|
* Unknown top-level or defaults keys are rejected for the same
|
|
reason: a declarative file must fail on typos, not silently ignore
|
|
them. Definition identity (name/version/skills) is echoed in the
|
|
creation response and, via the prompt header, in the stored prompt;
|
|
v1 adds NO new tasks-table column, so ``GET /tasks`` shows the
|
|
resolved fields and the header, not a separate ``agent`` column.
|
|
That column is v2, not something v1 pretends to have.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
from pathlib import Path
|
|
|
|
MAX_INSTRUCTIONS_CHARS = 8000
|
|
MAX_DESCRIPTION_CHARS = 1000
|
|
MAX_SKILLS = 20
|
|
NAME_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
|
|
EFFORTS = ("low", "medium", "high", "xhigh", "max")
|
|
APPROVALS = ("ask", "auto", "full")
|
|
EXECUTORS = ("local", "docker", "cloud")
|
|
_TOP_LEVEL_KEYS = ("name", "version", "description", "instructions",
|
|
"skills", "defaults")
|
|
_DEFAULTS_KEYS = ("model", "effort", "approval", "executor", "plan")
|
|
SKILLS_DIR = Path(__file__).resolve().parent / "skills"
|
|
|
|
|
|
def list_known_skills(skills_dir=None):
|
|
"""Sorted skill names present in the pack (dirs with SKILL.md)."""
|
|
base = Path(skills_dir) if skills_dir else SKILLS_DIR
|
|
names = []
|
|
try:
|
|
entries = sorted(base.iterdir())
|
|
except OSError:
|
|
return names
|
|
for entry in entries:
|
|
if entry.is_dir() and (entry / "SKILL.md").is_file():
|
|
names.append(entry.name)
|
|
return names
|
|
|
|
|
|
def _clean_str(value, field):
|
|
if not isinstance(value, str) or not value.strip():
|
|
raise ValueError("%s must be a non-empty string" % field)
|
|
return value.strip()
|
|
|
|
|
|
class AgentDefinition:
|
|
"""A parsed, validated agent definition."""
|
|
|
|
def __init__(self, name, version=None, description=None,
|
|
instructions=None, skills=None, defaults=None):
|
|
self.name = name
|
|
self.version = version
|
|
self.description = description
|
|
self.instructions = instructions
|
|
self.skills = list(skills or [])
|
|
self.defaults = dict(defaults or {})
|
|
|
|
def to_summary(self):
|
|
"""The identity echoed in the task-creation response."""
|
|
return {
|
|
"name": self.name,
|
|
"version": self.version,
|
|
"skills": list(self.skills),
|
|
}
|
|
|
|
|
|
def parse_agent_definition(data, known_skills=None):
|
|
"""Parse and validate one definition, or raise ValueError.
|
|
|
|
``known_skills`` is a list of pack skill names; when it is not
|
|
None, every listed skill must be in it. Passing None skips the
|
|
existence check (pure-function tests), it never invents a skill.
|
|
"""
|
|
if not isinstance(data, dict):
|
|
raise ValueError("agent must be an object")
|
|
unknown = sorted(set(data) - set(_TOP_LEVEL_KEYS))
|
|
if unknown:
|
|
raise ValueError("agent has unknown field(s): %s"
|
|
% ", ".join(unknown))
|
|
name = data.get("name")
|
|
if not isinstance(name, str) or not NAME_RE.match(name):
|
|
raise ValueError(
|
|
"agent.name must match [a-z0-9][a-z0-9_-]{0,63}")
|
|
version = None
|
|
if data.get("version") is not None:
|
|
version = _clean_str(data.get("version"), "agent.version")
|
|
if len(version) > 64:
|
|
raise ValueError("agent.version is too long")
|
|
description = None
|
|
if data.get("description") is not None:
|
|
if not isinstance(data.get("description"), str):
|
|
raise ValueError("agent.description must be a string")
|
|
description = data["description"]
|
|
if len(description) > MAX_DESCRIPTION_CHARS:
|
|
raise ValueError("agent.description is too long")
|
|
instructions = None
|
|
if data.get("instructions") is not None:
|
|
if not isinstance(data.get("instructions"), str):
|
|
raise ValueError("agent.instructions must be a string")
|
|
instructions = data["instructions"].strip()
|
|
if not instructions:
|
|
instructions = None
|
|
elif len(instructions) > MAX_INSTRUCTIONS_CHARS:
|
|
raise ValueError(
|
|
"agent.instructions exceeds %d characters"
|
|
% MAX_INSTRUCTIONS_CHARS)
|
|
skills = []
|
|
if data.get("skills") is not None:
|
|
raw_skills = data.get("skills")
|
|
if not isinstance(raw_skills, list):
|
|
raise ValueError("agent.skills must be a list")
|
|
if len(raw_skills) > MAX_SKILLS:
|
|
raise ValueError("agent.skills has too many entries")
|
|
for skill in raw_skills:
|
|
skill = _clean_str(skill, "agent.skills entry")
|
|
if skill not in skills:
|
|
skills.append(skill)
|
|
if known_skills is not None:
|
|
known = set(known_skills)
|
|
missing = [s for s in skills if s not in known]
|
|
if missing:
|
|
raise ValueError("agent.skills unknown skill(s): %s"
|
|
% ", ".join(missing))
|
|
defaults = {}
|
|
if data.get("defaults") is not None:
|
|
raw_defaults = data.get("defaults")
|
|
if not isinstance(raw_defaults, dict):
|
|
raise ValueError("agent.defaults must be an object")
|
|
unknown_defaults = sorted(set(raw_defaults) - set(_DEFAULTS_KEYS))
|
|
if unknown_defaults:
|
|
raise ValueError("agent.defaults has unknown field(s): %s"
|
|
% ", ".join(unknown_defaults))
|
|
if raw_defaults.get("model") is not None:
|
|
defaults["model"] = _clean_str(raw_defaults["model"],
|
|
"agent.defaults.model")
|
|
if raw_defaults.get("effort") is not None:
|
|
if raw_defaults["effort"] not in EFFORTS:
|
|
raise ValueError(
|
|
"agent.defaults.effort must be low, medium, high,"
|
|
" xhigh or max")
|
|
defaults["effort"] = raw_defaults["effort"]
|
|
if raw_defaults.get("approval") is not None:
|
|
if raw_defaults["approval"] not in APPROVALS:
|
|
raise ValueError(
|
|
"agent.defaults.approval must be ask, auto or full")
|
|
defaults["approval"] = raw_defaults["approval"]
|
|
if raw_defaults.get("executor") is not None:
|
|
if raw_defaults["executor"] not in EXECUTORS:
|
|
raise ValueError(
|
|
"agent.defaults.executor must be local, docker"
|
|
" or cloud")
|
|
defaults["executor"] = raw_defaults["executor"]
|
|
if raw_defaults.get("plan") is not None:
|
|
plan = raw_defaults["plan"]
|
|
if plan not in (True, False, 0, 1):
|
|
raise ValueError("agent.defaults.plan must be true/false")
|
|
defaults["plan"] = 1 if plan in (True, 1) else 0
|
|
if defaults.get("plan") and defaults.get("approval") == "full":
|
|
raise ValueError(
|
|
"plan 模式与 approval=full 冲突(只读 vs 完全访问)")
|
|
return AgentDefinition(name=name, version=version,
|
|
description=description,
|
|
instructions=instructions, skills=skills,
|
|
defaults=defaults)
|
|
|
|
|
|
def load_agent_definition(path, agents_dir=None):
|
|
"""Load one definition from a JSON file (operator/local use only).
|
|
|
|
This is NOT an API path: the server never loads a definition from
|
|
a caller-supplied path (that would be a file-read primitive). When
|
|
``agents_dir`` is given, the resolved path must stay under it, so
|
|
``../`` cannot escape the definitions directory.
|
|
"""
|
|
candidate = Path(path)
|
|
if agents_dir is not None and not candidate.is_absolute():
|
|
candidate = Path(agents_dir) / candidate
|
|
resolved = candidate.resolve()
|
|
if agents_dir is not None:
|
|
base = Path(agents_dir).resolve()
|
|
if base not in resolved.parents and resolved != base:
|
|
raise ValueError("agent definition path escapes agents dir")
|
|
try:
|
|
with open(resolved, encoding="utf-8") as f:
|
|
data = json.load(f)
|
|
except OSError as exc:
|
|
raise ValueError("cannot read agent definition: %s" % exc)
|
|
except ValueError as exc:
|
|
raise ValueError("agent definition is not valid JSON: %s" % exc)
|
|
return parse_agent_definition(data)
|
|
|
|
|
|
def resolve_task_fields(definition, body):
|
|
"""Resolve task fields: explicit body field wins, else definition.
|
|
|
|
``body`` is the task-creation body dict. Returns a dict with
|
|
``model``, ``effort``, ``approval``, ``executor`` (None when
|
|
absent from both) and ``plan`` (int 0/1). It never mutates either
|
|
input, and it never validates the resolved values -- the caller
|
|
(server task creation) runs its existing validation on them, so
|
|
a bad definition value is caught by the same 400 path as a bad
|
|
body value.
|
|
"""
|
|
body = body if isinstance(body, dict) else {}
|
|
defaults = definition.defaults if definition else {}
|
|
resolved = {}
|
|
for field in ("model", "effort", "approval", "executor"):
|
|
value = body.get(field)
|
|
if value is None:
|
|
value = defaults.get(field)
|
|
resolved[field] = value
|
|
if "plan" in body:
|
|
resolved["plan"] = 1 if body.get("plan") in (True, 1) else 0
|
|
elif defaults.get("plan") is not None:
|
|
resolved["plan"] = defaults["plan"]
|
|
else:
|
|
resolved["plan"] = 0
|
|
return resolved
|
|
|
|
|
|
def format_instructions_header(definition, prompt):
|
|
"""Prepend a definition's instructions to a prompt, marked.
|
|
|
|
Returns the original prompt unchanged when there is no definition
|
|
or no instructions, so callers that do not use definitions pay
|
|
nothing and see no header.
|
|
"""
|
|
if definition is None or not definition.instructions:
|
|
return prompt
|
|
header = "[Agent: %s" % definition.name
|
|
if definition.version:
|
|
header += " %s" % definition.version
|
|
header += "]"
|
|
return "%s\n%s\n\n%s" % (header, definition.instructions, prompt)
|