Agents¶
An agent is the unit the build dispatches to. Its role decides who is offered
a piece of work and who is available to check one; its bound (provider, model)
pair decides what runs it, and the pair is fixed for as long as the agent exists,
so "which model produced this" has an answer after the fact.
Every agent is a composition of immutable config (identity, skills, model, tool permissions, authority) and mutable runtime state (execution status, active task, cost accumulation). This page covers the identity layer. The HR lifecycle (hiring, firing, performance, evolution) lives on a dedicated HR & Agent Lifecycle page.
Agent Identity Card¶
Every agent has a comprehensive identity. At the design level, agent data splits into three layers:
- Config (immutable)
- Identity, skills, model preferences, tool permissions, and authority. Defined at hire time, changed only by explicit reconfiguration. Represented as frozen Pydantic models.
- Runtime state (mutable-via-copy)
- Current status, active task, conversation history, and execution metrics. Evolves during
agent operation. Represented as Pydantic models using
model_copy(update=...)for state transitions, never mutated in place. - Resolved provider view (never persisted)
- What the agent's assigned model can actually do. Belongs to the provider, not the agent, so it is resolved per request at the API boundary and never written to agent config. See Model capabilities on the wire.
Model capabilities on the wire¶
Two model-related fields ride on an agent, and they point in opposite directions:
model_requirement(input, config layer)- What the role demanded of a model when the matcher chose one:
priority,min_context,requires_vision,requires_reasoning, and optionally afamilyormodel_pattern. Persisted, round-trips through settings. Tool calling is deliberately absent, because the matcher applies it as a floor to every agent rather than as a per-role option. model_capabilities(output, resolved per request)- What the assigned model can actually do, projected by
api/controllers/agents/_model_capabilities.pyfrom the provider'sModelMetadata:supports_reasoning,supports_vision,tool_calling, andmetadata_source.
AgentConfigResponse lists the agent fields it exposes explicitly rather than inheriting from
AgentConfig, so a field added to the persisted schema reaches the wire only when someone
adds it here too, and a response can never be handed to a persistence path typed for
AgentConfig. The provider-level counterpart is GET /providers/{name}/models (see
Providers); this is the agent-facing projection of the same metadata.
tool_calling is a named state rather than a boolean because "never observed" and "proven
incapable" are opposite facts that a truthiness check would conflate:
| Value | Meaning |
|---|---|
unverified |
No tool call has been observed yet. Not a fault. |
verified |
A real tool call succeeded. |
failed |
Repeated runtime failures proved the model cannot call tools. |
model_capabilities is null for two unrelated reasons, so model_capability_status names
which one applies. Without it a consumer reading the null alone cannot tell one agent
pointing at a deleted model from an entire org whose provider config momentarily could not
be read, and the dashboard would report the whole roster as broken during a settings outage.
| Value | Meaning |
|---|---|
resolved |
The binding resolved; model_capabilities is populated. |
unresolved |
The binding matches no configured model: unassigned, or stale after a removed model. |
provider_config_unavailable |
Provider config could not be read, so no binding was resolvable. Says nothing about this agent's binding. |
Reading provider config is failure-tolerant on every endpoint that projects capabilities
(providers_for_capabilities). Capabilities are derived display data layered onto an
operation with its own result: on a mutation the write has already committed by the time
they resolve, so a settings failure must not report a successful create or reorder as an
error and invite a duplicate retry. Tolerance covers any ordinary failure, not just
SettingsError, because an unwired resolver and a dropped store connection reach the
caller identically; only critical errors and cancellation still propagate.
For the same reason a mutation projects its response before publishing its WebSocket event. Publishing cannot be retracted, so projecting afterwards would let a projection failure fail the response while subscribers reload against a change the requester was shown as an error.
The agent id is a stable UUID derived deterministically from the agent name
(stable_agent_id(name) = uuid5(namespace, name) in core.types). The config layer and the
runtime registry derive the same id independently from the name, so a config-sourced agent and
its registered AgentIdentity share one id without coordination. REST routes address agents by
this id (/agents/{agent_id}), matching the UUID that group chat addresses participants by.
The Bound Unit¶
An agent is a fixed (role, model) pair. The role says what the agent is FOR, which is what
selection, routing and the decomposition planner reason about; the bound (provider, model)
says what actually runs it, and nothing in the loop re-points it (see
No Bound-Pair Rewrite). How an agent's output READS is not a property of the
agent at all: it is governed centrally by
Output Style Policy, which applies the same house rules to every
agent and enforces the hard ones deterministically at the boundary where work is kept or sent.
Skill Model¶
Agent skills are represented as structured capability descriptions aligned with the A2A AgentSkill specification, enabling lossless bidirectional mapping between internal skills and external Agent Card capabilities.
from pydantic import BaseModel, ConfigDict
from synthorg.core.types import NotBlankStr
class Skill(BaseModel):
"""Structured capability description, A2A AgentSkill-aligned."""
model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid")
id: NotBlankStr # e.g. "code-review"
name: NotBlankStr # e.g. "Code Review"
description: str = "" # human-readable capability description
tags: tuple[NotBlankStr, ...] = () # searchable tags for multi-faceted matching
input_modes: tuple[str, ...] = ("text/plain",) # MIME types accepted
output_modes: tuple[str, ...] = ("text/plain",) # MIME types produced
proficiency: float = 1.0 # 0.0--1.0, agent's proficiency level
class SkillSet(BaseModel):
"""Agent skill inventory, split into primary and secondary."""
model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid")
primary: tuple[Skill, ...] = ()
secondary: tuple[Skill, ...] = ()
| Field | A2A AgentSkill Equivalent | Purpose |
|---|---|---|
id |
id |
Unique skill identifier |
name |
name |
Human-readable display name |
description |
description |
Capability description for semantic matching |
tags |
tags |
Searchable tags for multi-faceted routing |
input_modes |
inputModes |
MIME types the agent accepts for this skill |
output_modes |
outputModes |
MIME types the agent produces for this skill |
proficiency |
- | SynthOrg-specific: proficiency level for quality-aware routing |
Defaults:
input_modesandoutput_modesdefault to("text/plain",); internal agents that only handle text do not need to specify these fieldsproficiencydefaults to1.0, only meaningful when comparing agents with the same skill at different proficiency levelsSkillSetrejects string entries, duplicate skill IDs within a tier, and overlap betweenprimaryandsecondary(pre-alpha, no compatibility coercion from any prior string-based shape)
Routing impact: AgentTaskScorer uses the structured skill data directly. Primary
skill overlap is weighted at 40% and secondary at 20%, each contribution scaled by the
agent's proficiency for every matched skill (default 1.0, which reproduces
boolean-match scoring). When a subtask declares required_tags, matched skills whose
tags cover every required tag earn an additional 10% bonus. Proficiency thus drives
quality-aware routing ("route to the agent with the highest Python proficiency") and
tags drive multi-faceted matching when callers opt in.
Maintenance: Skills are template-seeded at hire time (company templates provide default skill sets per role) and human-editable via the REST API. Auto-derivation from task completion history is not yet implemented.
Tool Namespaces¶
Tools are grouped by namespace and gated by ToolPermission:
| Namespace | Permission | Tools |
|---|---|---|
communication.async_tasks |
DELEGATION |
start_async_task, check_async_task, update_async_task, cancel_async_task, list_async_tasks |
The communication.async_tasks tools provide supervisor-facing async task
management wrapping TaskEngine (see Async Delegation).
Agent Configuration Example¶
An agent is a bound (role, model) unit. How its output reads is governed
separately and deterministically; see
Output-Style Policy.
The example below is the whole agent record, which spans both layers rather
than matching either model exactly. AgentConfig is what an operator writes
and what company.agents persists; AgentIdentity is what identity_from_config
derives at bootstrap and what the engine runs. Both forbid extra keys, so each
field marked below belongs to one layer and is rejected by the other.
Full agent record across both layers
# --- Config layer: AgentConfig, plus the identity-only fields ---
agent:
# id is derived deterministically from name (uuid5); not user-set.
id: "<derived-from-name>"
name: "Sarah Chen"
role: "Senior Backend Developer"
department: "Engineering"
skills: # AgentIdentity only
primary:
- id: python
name: Python
description: "Backend development with Python 3.14+"
tags: [backend, scripting]
proficiency: 0.95
- id: litestar
name: Litestar
description: "Async web framework API development"
tags: [backend, api, async]
- id: postgresql
name: PostgreSQL
description: "Relational database design and optimisation"
tags: [database, sql]
- id: system-design
name: System Design
description: "Distributed system architecture"
tags: [architecture, backend]
secondary:
- id: docker
name: Docker
tags: [devops, containers]
- id: redis
name: Redis
tags: [database, caching]
- id: testing
name: Testing
tags: [quality, automation]
model:
provider: "example-provider"
model_id: "example-capable-001"
temperature: 0.3
top_p: null # nucleus threshold; moves with temperature
reasoning_effort: null # null defers to the per-stakes ladder
max_tokens: 8192
capability: "capable" # derived by the matcher from the selected model's context window
model_requirement: # AgentConfig only: requirements from template
priority: "balanced" # quality / balanced / speed / cost
min_context: 0
requires_vision: false # hard-require image input
requires_reasoning: false # hard-require extended reasoning
family: null # e.g. "example-expert": pin newest in family
model_pattern: null # e.g. "example-*": pin newest matching id
memory:
type: "persistent" # persistent, project, session, none
retention_days: null # null = forever; also agent-level global default
retention_overrides: [] # per-category overrides, e.g. [{category: "semantic", retention_days: 365}]
tools:
access_level: "standard" # sandboxed | restricted | standard | elevated | custom
allowed:
- file_system
- git
- code_execution
- web_search
- terminal
denied:
- deployment
- database_admin
# Progressive disclosure: list_tools, load_tool, and
# load_tool_resource are always available regardless of
# access_level. L1 metadata is visible for all permitted
# tools; L2/L3 content respects the same permission rules
# as tool invocation.
authority:
can_approve: ["junior_dev_tasks", "code_reviews"]
reports_to: "engineering_lead"
can_delegate_to: ["junior_developers"]
budget_limit: 5.00
autonomy_level: null # full, semi, supervised, locked (overrides defaults)
strategic_output_mode: null # option_expander, advisor, decision_maker, context_dependent (see strategy.md)
hiring_date: "2026-02-27" # AgentIdentity only
status: "active" # AgentIdentity only: active, on_leave, terminated
Runtime State¶
The runtime state layer (in engine/) tracks execution progress using frozen models
with model_copy:
- TaskExecution wraps a Task with evolving execution state: status transitions,
accumulated cost (
TokenUsage), turn count, and timestamps. - AgentContext wraps
AgentIdentity+TaskExecutionwith a unique execution ID, conversation history, cost accumulation, turn limits, and timing. - AgentRuntimeState provides a lightweight per-agent execution status snapshot
(idle / executing / paused) for dashboard queries and graceful-shutdown discovery.
Persisted via
AgentStateRepository, independent of the checkpoint system.
Identity Versioning¶
AgentRegistryService creates VersionSnapshot[AgentIdentity] records for
register() and update_identity() (charter/config changes such as model
swaps and role changes). update_status() (status transitions) is not
versioned: status changes are transient runtime state, not charter mutations.
This provides a full audit trail of charter changes and enables DecisionRecord
entries to cite the exact charter version that was active during execution.
Generic Infrastructure¶
The versioning system lives in src/synthorg/versioning/ and is intentionally
entity-agnostic so it can be reused for other versioned entity types:
VersionSnapshot[T](versioning/models.py): Generic frozen Pydantic model with fieldsentity_id,version,content_hash,snapshot: T,saved_by,saved_at. Version numbers are monotonically increasing per entity.compute_content_hash(model)(versioning/hashing.py): SHA-256 ofjson.dumps(model.model_dump(mode="json"), sort_keys=True); stable across field-ordering variations in Pydantic serialisation.VersioningService[T](versioning/service.py): Wraps aVersionRepositoryto provide content-addressable snapshot creation.snapshot_if_changedskips the write when the content hash matches the newest stored version.VersionRepository[T](persistence/version_protocol.py): Generic protocol withsave_version(idempotent INSERT OR IGNORE),get_version,get_latest_version,get_by_content_hash,list_versions,count_versions,delete_versions_for_entity.SQLiteVersionRepository[T](persistence/sqlite/version_repo.py): Parameterised bytable_name,serialize_snapshot, anddeserialize_snapshotcallables. Table name is validated at construction against^[a-z][a-z0-9_]*$to prevent SQL injection.
Agent Identity Storage¶
Identity versions are persisted in the agent_identity_versions table (see
schema.sql). The SQLitePersistenceBackend.identity_versions property exposes a
pre-configured SQLiteVersionRepository[AgentIdentity].
AgentRegistryService accepts an optional VersioningService[AgentIdentity]
dependency (constructor injection). The app factory (api.app:create_app) auto-wires
this dependency during startup so identity versioning is enabled out of the box;
no manual configuration required. When wired:
register()snapshots the initial identity immediately after storing it.update_identity()snapshots the updated identity after applying the change.evolve_identity()snapshots the restored identity on rollback.- All calls are failure-tolerant: versioning failures are logged at WARNING and do not interrupt the registry mutation.
REST API¶
Identity version history is exposed under /api/v1/agents/{agent_id}/versions
(paths in the table below are relative to that base):
| Method | Path (relative) | Guard | Description |
|---|---|---|---|
GET |
/ |
read | Paginated list of version snapshots (offset, limit default 20) |
GET |
/{version_num} |
read | Single version snapshot by monotonic version number |
GET |
/diff?from_version=N&to_version=M |
read | Field-level AgentIdentityDiff between two versions (from_version < to_version required) |
POST |
/rollback |
write | Restore a prior version. Body: {"target_version": <int>, "reason": "<text>"} (reason optional). Executed via evolve_identity, producing a new snapshot whose content hash equals the restored version; rollbacks never mutate history. |
All endpoints additionally verify that the stored snapshot's encoded owner id
matches the path agent_id (cross-agent rows are rejected with 400).
Identity Diff¶
src/synthorg/engine/identity/diff.py provides identity-specific diff logic:
IdentityFieldChange: A single field-level change withfield_path(dot-notation, e.g.authority.budget_limit),change_type(modified/added/removed), andold_value/new_value(JSON strings).AgentIdentityDiff: Full diff summary withagent_id,from_version,to_version,field_changes, and a human-readablesummary.compute_diff(agent_id, old, new, from_version, to_version): Recursively comparesmodel_dump(mode="json")output, descending into nested sub-models and dicts. Produces changes sorted byfield_path.
DecisionRecord Integration¶
When ReviewGateService._record_decision runs, it looks up the executing agent's
newest identity version from persistence.identity_versions. If found, it injects a
charter_version entry into the DecisionRecord.metadata dict:
This lookup is failure-tolerant. On QueryError the decision record is written with
{"charter_version_lookup_failed": True} in its metadata so operators can
distinguish lookup failures from the no-version-found case (where metadata
is None). The failure is logged at WARNING. No schema migration is required:
the metadata field on DecisionRecord was designed as a forward-compatible
extension point.
Built-in Roles¶
BUILTIN_ROLES in src/synthorg/core/role_catalog.py carries the
shipped role catalog. Every entry (CEO, Backend Developer, QA
Engineer, etc.) is a role definition only; it becomes a real
AgentIdentity when an operator staffs it. Two roles are distinguished
not by being special-cased but by what they do: they judge finished
work rather than performing it, and both live in Quality Assurance.
- Completion Reviewer (
name="Completion Reviewer", department: Quality Assurance). The independent peer reviewer of the completion oracle. A holder is selected per review whenengine.completion_oracle_enabledis true (on by default); it reads a completing deliverable's acceptance criteria and its build and test evidence, and files a verdict. See Verification & Quality: Completion Oracle Gate. - Red Team (
name="Red Team", department: Quality Assurance). The adversarial sceptic. A holder is selected per evaluation whenCompanyConfig.security.red_team.enabledis true, and runs as a gate before IN_REVIEW -> COMPLETED for deliverables whosestakesmeetengine.red_team_min_stakes(defaulthigh). See Security: Adversarial Red-Team Gate.
Both are ordinary staffable roles: an operator gives one to an agent
through the same role-assignment surface as any other, the holder appears
in the roster and in GET /agents/active, and its verdicts are comparable
per agent and per model like the rest of its work. Neither is
boot-instantiated; a synthetic identity for either is rejected by
check_no_synthetic_agent_identity.py, because a role nobody can be given
is authority nobody can see.
core/role_catalog.py::role_is_gate_role is the one declaration that a role
judges rather than performs, and three properties hang off it.
The judge is selected per review, and is never the author.
hr/role_staffing.py answers "who holds this role, and which of them fits this
work" for both gates, so there is one selection rule rather than two that drift.
Three independent things keep the judge off its own work: selection drops the
executor from the candidate pool before anything else happens; the gate refuses
a reviewer identity equal to the executor and escalates instead of reviewing,
which catches an identity it did not itself choose; and a row-level CHECK on
each verdict archive refuses any row naming one agent as both executor and
judge, so a self-review cannot be recorded even if both earlier layers were
bypassed.
Within the remaining pool, holders who already worked the reviewed initiative are preferred, and widening org-wide is logged rather than silent. Capability fit then decides: an exact rung, else the nearest higher, else the nearest lower, logged as under-capability. The requirement is floored at the executor's own rung, because the stakes and complexity that set the bar are proposed by the agent that decomposed the work, and a bar with no floor lets the thing under review bid its own judge down.
The judging session is narrowed. A roster agent carries whatever grants its
day job needs, and an injection planted in the artefact under review runs inside
the session reading it. engine/review_session.py::as_review_session therefore
dispatches a copy: STANDARD tool access, the verdict tool allowed by name, no
MCP capabilities, EXTERNAL_DATA withheld by category, SUPERVISED autonomy.
Identity, role, department and bound model are untouched, so the verdict is
still attributed to the real agent and still runs on the pair its operator
chose. This narrows the session for the duration of the dispatch, never the
roster.
A gate role cannot own plan work. A gate role is staffed, so it appears in
every roster read; but it judges rather than performs, so it is not something a
plan item can be owned by. engine/decomposition/context.py::roster_from_agents
is the single owner of "which roles may own a plan item" and filters gate roles
out of the list the planner is offered, because a planner offered one takes it,
and the party that judges then becomes the author of what it judges.
An org that staffs neither role does not silently skip review: the gate parks the task and says which role is missing (see Nobody holds the role). Every shipped company template staffs a Completion Reviewer for that reason, and the security-hardened ones also staff a Red Team.
See Also¶
- HR & Agent Lifecycle: role catalog, reporting-graph authority, hiring, firing, performance, evolution, client agents
- Organisation: company types, departments, templates
- Tools & Capabilities: tool access levels, sandboxing
- Design Overview: full index