AGENT SKILL

define-the-agent-yaml

Walk every field in `harbor.yaml` — REQUIRED (identity + llm), COMMON (planner / memory / state / tools / skills / governance), ADVANCED (server / telemetry / artifacts / events / sessions / tasks / distributed). Use when editing the agent config beyond the scaffolded defaults.

License 许可
Apache-2.0
所属仓库
hurtener/Harbor
最近更新
2026-11-15
Stars
★ 6
Forks
1

01 前言 Front Matter

name
define-the-agent-yaml
license
Apache-2.0
metadata
{ "verbs": "validate", "surface": "agent-yaml", "framework": "harbor" }
description
Walk every field in `harbor.yaml` — REQUIRED (identity + llm), COMMON (planner / memory / state / tools / skills / governance), ADVANCED (server / telemetry / artifacts / events / sessions / tasks / distributed). Use when editing the agent config beyond the scaffolded defaults.

03 SKILL.md 正文

Define the agent yaml

harbor.yaml is the single declarative file Harbor's runtime reads at boot. It's tiered by importance — REQUIRED at the top (the binary won't boot without it), then COMMON (the knobs you'll edit most), then ADVANCED (every other lever). Every absent key gets a documented default; the only fields you MUST set are identity + llm.

Pair this skill with harbor validate ./harbor.yaml — the validator is the loudest, most file:line-precise feedback you'll get on a yaml mistake. Run it after every edit.

REQUIRED — identity + llm

identity

The identity block configures JWT verification — the Runtime's authentication boundary. Every Protocol call carries a JWT; identity decides what algorithm to trust and where to fetch the public key.

identity:
  jwt_algorithms: [RS256]                      # allowlist: RS256/RS384/RS512/ES256/ES384/ES512
  issuer: https://issuer.example.com           # exact match against the JWT `iss` claim
  audience: my-agent                           # exact match against the JWT `aud` claim
  jwks_url: https://issuer.example.com/.well-known/jwks.json
  jwks_max_stale: 1h                           # OPTIONAL — max age a cached JWKS snapshot is honored without a refresh (0/omit = 1h default)

For local dev, the scaffold drops placeholders — these pass harbor validate but reject any real token. harbor dev mints its own ephemeral signing key and bypasses the issuer/jwks_url path entirely (see run-the-dev-loop). For production, point issuer + jwks_url at your real IdP. HS256 / none are forbidden — the loader rejects them at boot. jwks_max_stale bounds how long the verifier keeps trusting a cached signing key when your IdP is unreachable: past this age it fails closed (rejects tokens) rather than serving a key your IdP may have revoked. Omit it (or set 0) for the safe 1h default; a negative or below-1m value is rejected at boot. There is no way to disable the ceiling — it tunes, it does not remove. Pair a tight ceiling with overlapping IdP signing keys and short token TTLs.

llm

Pick exactly one provider block from the scaffolded examples. Bifrost (Harbor's LLM driver) speaks many providers under one wire surface; you swap providers by swapping the block, not by changing code.

llm:
  driver: bifrost                              # only driver shipped in V1.1
  provider: openrouter
  model: anthropic/claude-haiku-4.5
  api_key: env.OPENROUTER_API_KEY              # `env.NAME` resolves via os.Getenv
  timeout: 60s
  model_profiles:                              # effectively REQUIRED — one entry per model you use
    anthropic/claude-haiku-4.5:
      context_window_tokens: 200000            # the runtime uses this for context-window budgeting

model_profiles.<llm.model>.context_window_tokens is what the runtime consults for context-window budgeting. There is no silent fallback: a model with no matching model_profiles entry hard-fails the FIRST LLM call with ErrUnsupportedModel (fail-loudly — the error names the missing model_profiles[<model>] key). Set a profile for every model you reference. See wire-the-llm-provider.

COMMON — planner, memory, state, tools, skills, governance

planner

V1.1 ships one planner: react. The block tunes its budget and gives the planner extra domain guidance.

planner:
  max_steps: 12                                # planner step tranche: steps per cycle before the run is
                                               # parked for continuation — a typed constraints_conflict pause
                                               # (cause max_steps_exceeded), never forced finalisation;
                                               # 0 (default) = driver default (12)
  extra_guidance: |
    Voice/tone rules. Hard negatives. Safety notes.
    Operator-supplied; injected into the planner's system prompt.
  reasoning_replay: never                      # or `text` to round-trip the trace into the next turn

max_steps is a continuable tranche, not a termination knob. When a tranche of planner steps is consumed without a terminal Finish, the run is parked through the unified pause primitive — a typed constraints_conflict pause carrying {cause: max_steps_exceeded, max_steps, steps_observed} — instead of being forced to finalise. An authorised RESUME continues the SAME run with a fresh tranche (the tranche counter resets; the cumulative trajectory is untouched), so long-running work spans repeated cycles as ONE run (D-418); a fresh process cannot resume a parked run and answers the typed ErrRestartUnavailable (D-417). Zero (the default) resolves to the driver default (12) and never means unbounded; the planner-side per-tranche breaker ends the cycle with the typed NoPath Finish (max_steps_exceeded), and the runtime's outer ErrMaxStepsExceeded guard (default 64) remains the runaway backstop when tranche pausing is unavailable. See docs/CONFIG.md › planner.max_steps.

The default ReAct prompt already tells the agent to respect developer/operator scope, prohibitions, approvals, and output rules supplied through extra_guidance. Define the actual domain rules here; do not rely on an agent name to imply them. It also requests brief progress prose alongside tool calls during multi-step work, not private reasoning. For a silent/JSON-only consumer, explicitly suppress progress prose in trusted guidance. A custom base prompt replaces these defaults. Prompt instructions do not replace runtime permissions, tool restrictions, or approvals. See planner.extra_guidance for the trust tiers, replacement behavior, and validation limits.

memory

Multi-turn execution context. The default is cumulative rolling_summary; choose none explicitly for stateless agents. All adapters share the execution owner, and the state configuration below determines durability.

memory:
  driver: inmem                                # administrative adapter; persistence uses state
  strategy: rolling_summary                    # or explicit `none`
  recent_turns: 20                              # detailed tail, not checkpoint history
  budget_tokens: 8000                          # working-input target, NOT an output limit

The working-input compaction target is configured with memory.budget_tokens; the removed planner.token_budget is rejected, not silently translated. With rolling_summary, zero derives the target from the effective model's input capacity and output reservation. A positive target also enables within-run compaction for stateless agents. Fresh results remain protected, so this is a soft working target, not permission to truncate evidence to fit.

Separately, memory.summarizer.max_tokens sets the compaction call's completion allowance (including provider reasoning), not the driving model's output limit. Omitted/zero preserves 2048; a positive deployment-specific value remains subject to governed model capacity. YAML or HARBOR_MEMORY_SUMMARIZER_MAX_TOKENS changes require a restart. See the reference.

memory.summarizer.max_calls separately limits chronological calls per compaction (zero/omitted: 16); HARBOR_MEMORY_SUMMARIZER_MAX_CALLS is its environment override. It is restart-required and never authorizes extra tool work or a partial summary. Increasing it can increase time and spend. recent_turns remains configurable with a default of 20 and no hardcoded 32-turn maximum. See the maintenance allowance.

On the PR #779 cumulative-memory branch, rolling_summary also enables the shared served/embedded execution-context path. recent_turns: 20 (or zero) bounds detailed history, not checkpoint age. The separate sessions.retained_context_turns and SDK activation option are removed. Omitted settings and newly scaffolded agents select this behavior. The old pair-summary engine is removed; truncation and memory.recovery_backlog_max are rejected rather than enabling a second pipeline.

The WAL trap: dsn: ./... inside the project directory triggers harbor dev's fsnotify watcher and reboots the runtime in a loop. Default-drop the DSN at /tmp/harbor-validation/my-agent-memory.sqlite or ~/.harbor/my-agent-memory.sqlite. See run-the-dev-loop §3.

state

Durable run/task/session state. The default inmem driver is process-local — runs disappear on restart. Flip to SQLite for single-node persistence, Postgres for multi-replica.

state:
  driver: sqlite
  dsn: /tmp/harbor-validation/my-agent-state.sqlite   # WAL trap caveat applies

configuration_state (optional)

Use this when agent settings must survive restart but conversations must not. Omitting it keeps the existing shared state behavior. It accepts the same driver, secret dsn and PostgreSQL migration_mode as state; environment overrides are HARBOR_CONFIGURATION_STATE_DRIVER, HARBOR_CONFIGURATION_STATE_DSN and HARBOR_CONFIGURATION_STATE_MIGRATION_MODE.

Keep state.driver: inmem and the other conversation stores ephemeral, then select a persistent configuration_state driver. Agent revisions, lifecycle, configuration journals and explicitly saved session settings use that store; messages, summaries and tool history do not. Do not claim that this disables independently configured durable events, artifacts or external memory tools. Apply PostgreSQL migrations on a direct endpoint before using verify with a transaction pool. Switching stores requires an explicit configuration restore; there is no automatic copying or recovery of lost in-memory settings.

tools

Three sources: built_in (tools shipped in the harbor binary; opt-in by name), mcp_servers (MCP southbound subprocesses Harbor spawns at boot), and http_manifests (UTCP-style YAML manifests describing HTTP endpoints as tools, loaded at boot — see docs/CONFIG.md › tools.http_manifests; tools.entries[] OAuth/approval/loading bindings apply to manifest tools by name like any other).

tools:
  built_in:
    - clock.now
    - text.echo
  mcp_servers:
    - name: weather
      transport_mode: stdio                           # auto / sse / streamable_http / stdio
      command: [uvx, mcp-weather]                      # argv form; required for stdio
      headers: { Authorization: "Bearer ${env.WEATHER_TOKEN}" }   # HTTP transports; redacted as secrets
      keep_alive: 30s                                  # session-ping interval; 0 disables
      policy:                                          # optional per-server tool reliability defaults
        timeout_ms: 60000                              # per-attempt deadline (default 30000)
        max_attempts: 4                                # total attempts incl. the first

The planner discovers every MCP server's tools at boot — there's no per-server enable flag; listing the server registers its tools. Built-in tools live in the harbor binary — list clock.now to enable, omit to disable. MCP servers are external processes; see configure-memory-and-skills for the skill-vs-tool axis.

Southbound OAuth binding — the credential-sink allow-list is mandatory (D-300). When an mcp_servers[] connection sets oauth_provider: <name> (a per-identity bearer injected per call), the named tools.oauth_providers[] entry MUST declare a non-empty allowed_downstream_hosts that lists the connection's host — the credential-plane invariant is that no admin-writable field determines where a credential is sent, so the sink set is boot-pinned. A bound provider with no allow-list, or a connection host that isn't listed, fails at boot (not at first call). See docs/CONFIG.md › tools.oauth_providers and tools.oauth_credential_brokers.

Per-user credential injection for receiver-style servers (D-341). A server that expects its credential handed to it DIRECTLY on each request — an arbitrary header, an Authorization: Basic value, or a _meta key — instead of pulling it via RFC 8693 uses an mcp_servers[] injection: mapping instead of oauth_provider. The runtime SOURCES the acting user's credential from the named broker per call (the SAME per-user broker-pull as oauth_provider — never held, never client-pushed) and INJECTS it in the declared, non-secret form: provider (a declared tools.oauth_providers[] broker) + form: header|basic|meta + the target key (header: / basic_username: / meta_key:). Only the pulled value is secret. It is mutually exclusive with oauth_provider / tool_oauth_providers / a static Authorization header (one auth mode per connection), the broker's allowed_downstream_hosts must list the connection host (fail-closed), and the target header / _meta leaf key MUST be a redaction-covered credential key (a -api-key / -token / -secret-shaped segment) so the audit redactor always holds the injected value to ***. A broker error fails the call loud (never an unauthenticated send). See examples/harbor.yaml › tools.mcp_servers[].injection.

_meta keys are PATHS, and two rules now fail at BOOT (D-362). A dotted key nests: injection.meta_key: vendor.api_key writes _meta.vendor.api_key, and — as of v1.24 — a dotted meta_annotations key does the same instead of landing as one literal flat key. One _meta namespace, one meaning, whichever mechanism wrote into it. A harbor.yaml that validated before can now fail to start, because two constraints that previously existed only on the agent_config.add_mcp_connection wire path now apply at boot as well:

  • A 16-segment depth cap on any declared _meta path, injection.meta_key included. A boot-declared path deeper than the cap used to be accepted where the identical wire-declared one was refused; both now consult one constant. No shipped config nests past two segments.
  • No two declared _meta paths on one connection may collide — be equal, or be a prefix of one another — across meta_annotations keys AND injection.meta_key. So {vendor: x, vendor.id: y} is refused, and so is a flat vendor annotation alongside injection.meta_key: vendor.api_key. That second shape previously discarded the operator's annotation silently at merge time and made the merged _meta depend on map iteration order.

Also newly refused: a key whose whole value or any dot-segment is reserved (tenant.foo now fails; the whole-key check is retained, because it is the arm that sees the spec-reserved io.modelcontextprotocol/ namespace), and a path with an empty segment (a..b). Run harbor validate against your config before upgrading. See docs/CONFIG.md › tools.mcp_servers[].meta_annotations.

Handing an MCP tool a large document by reference (D-359). A remote tool that must READ something large — a stored CSV, an uploaded PDF, a prior tool's materialised result — should not be given it through the model's context. Declare the connection byte-eligible and map the parameters that carry artifact bytes:

tools:
  mcp_servers:
    - name: docstore
      transport_mode: streamable_http
      url: https://docs.internal.example.com/mcp
      artifact_byte_eligible: true
      artifact_params:
        ingest_document: [content]     # server-side tool name -> parameter names

The model writes an artifact id; the runtime resolves it under the run's own (tenant, user, session) and writes the resolved bytes into the outbound call as RFC 4648 §4 standard base64. artifact_params REQUIRES artifact_byte_eligible (a mapping without it is refused, never silently ignored — the flag IS the containment boundary), both are http-only (refused on stdio, explicit or auto-selected from a command-only config), and each mapped parameter must be DECLARED and declared string-typed in the server's own discovered inputSchema — an absent or non-string parameter fails the attach loudly rather than the first call silently. One substituted value is bounded by tools.mcp_artifact_egress_max_bytes (default 8 MiB); an oversize artifact fails loud rather than truncating, because a half-delivered document is a corruption, not a bounded read. Every substitution emits mcp.artifact_egressed — ids, sizes and a sha256: digest, never the bytes — fail-closed before the wire request, so a substitution that could not be recorded does not happen. An optional mapped slot (a trailing ?) is skipped when absent, null, or empty / whitespace-only; a non-empty value still follows the strict resolver path. Two things to weigh before enabling it: artifact bytes are stored as authored (unredacted), so a byte-eligible connection can move whatever an artifact contains; and a byte-mapped parameter is not reachable from an MCP App's tool callback (no run, so no seated resolver — the call fails loud). It widens which SERVERS may receive a user's content, never which artifacts a run can reach. See docs/CONFIG.md › tools.mcp_servers[].artifact_byte_eligible and tools.mcp_artifact_egress_max_bytes.

For long research runs, also list the session artifact helpers next to artifact_fetch: artifact_search (grep inside a stored text result), artifact_list (page and filter the session's artifacts, including earlier large tool results) and artifact_note (keep a short note on one; needs the event bus, which every assembled runtime has).

runtime.run_limits.{max_duration, max_tokens} bounds every run (both optional; unset means no limit). Reaching a limit ends the run with one tool-free final answer and finish reason limit_reached, never a failure. A per-agent agent-config run_limits section replaces it and start.run_limits can only tighten it — see use-the-harbor-protocol.

runtime.hooks.run_completion.{tool, timeout} names a catalog tool that receives every run's transcript at completion — see docs/CONFIG.md › Runtime. runtime.naming.{auto, after_turns, repeat_every, max_repetitions, max_title_len, model} is the opt-in, default-off session auto-naming fleet default: with it enabled, the runtime titles a session itself at each run's terminal boundary via one governed LLM call over a bounded transcript digest (max_repetitions is required >= 1 whenever repeat_every > 0 — no unlimited value). A per-agent agent-config naming section overrides this default; see docs/CONFIG.md › Runtime.

skills

Skills are token-savvy DB-backed playbooks the planner searches by name. Distinct from "operator skills" (the docs/skills/ directory you're reading right now) — these are runtime skills the planner consults during a reasoning turn.

skills:
  driver: localdb
  dsn: ./my-agent-skills.sqlite                # WAL trap caveat applies
  directory:                                   # optional — the per-turn <skills_context> browse window
    pinned: [triage-incident]                  # anchored first, declaration order
    max_entries: 10                            # 0/unset → planner.skills_context_max (default 5)
    selection: pinned_then_recent              # the one wired value (pinned_then_top is rejected: not yet wired)

Ingest skills with harbor skill import <path> and remove them with harbor skill rm <name> — both operate on this block's store.

Boot-declared agent skill packs (HA-66, v1.28). skills.boot_agent_packs loads node-local operator skills for the resolved boot/default agent at boot. It REQUIRES a configured skills.driver + skills.dsn — a declaration on an unconfigured skills block fails boot loud. Each entry binds EXACTLY ONE (tenant_id, agent_id): an exact tenant key and the runtime-resolved boot agent (no wildcards, no tenant-wide application; honored only when the effective agent resolves to the declared agent_id). directory is the pack root, resolved relative to the config file's directory — never the process CWD; each include entry is one package directory containing exactly one case-sensitive top-level regular UTF-8 SKILL.md, and the v1.28 contract is resource-free (no auxiliary resource payloads ride alongside the skill body). Loading is strict, eager, and immutable before readiness: symlinks / hardlinks / special files / traversal / duplicates are rejected, the loaded set is fixed for the process lifetime, and file changes take effect only on a restart / new runtime — never hot-reloaded. Boot packs stay node-local: each node reloads them from its own files at boot, so a change on one node never converges the others even over a shared Postgres skill store. Boot-pack items compose with the agent's durable revision into ONE operator tier (same canonical name + same content hash dedupes; differing hashes fail loud; combined count capped at 256). Headless RunOnce against a boot-pack agent is unsupported and fails loud. Full contract: docs/CONFIG.md › skills.boot_agent_packs.

governance

Per-identity cost ceilings + rate limits + max-token caps, keyed by tier.

Declared tiers are enforced. A populated identity_tiers block composes the enforcement subsystem at boot: the budget ceiling fails over-budget calls with ErrBudgetExceeded, the token bucket with ErrRateLimited, and the per-call cap with ErrMaxTokensExceeded — each emitting a matching governance.* event you can watch on the events stream. The same block drives the read-only governance.posture Protocol surface.

governance:
  default_tier: free
  identity_tiers:
    free:
      budget_ceiling_usd: 5.00                 # enforced cap per (tenant, user, session)
      max_tokens: 4096                         # per-call MaxTokens cap
      rate_limit:                              # token bucket per (identity, model)
        capacity: 100000
        refill_tokens: 50000
        refill_interval: 1h

Empty identity_tiers: {} = fully latent (the default).

ADVANCED — every other lever

The scaffold drops a commented summary of advanced defaults. The full reference is docs/CONFIG.md. The blocks you most often touch:

  • server: bind_addr (default 127.0.0.1:8080 for harbor serve; harbor dev always binds :18080), allowed_origins (CORS allowlist for multi-process Console), shutdown_grace_period (drain timeout for hot reload).
  • telemetry: log_format (json / text), log_level (debug / info / warn / error), service_name (OTel resource).
  • observability: rollups.{driver,dsn} (HA-65) wires the durable observability rollup projection — empty block = unwired; driver inmem / sqlite / postgres, dsn required for sqlite / postgres; restart-required.
  • artifacts: driver (inmem / fs / sqlite / postgres), heavy_output_threshold_bytes (the LLM-edge context-leak guard, default 131072 / 128 KiB — see RFC §6.5). It bounds what may enter a model's context window only; Console-facing Protocol replies (pause.list, memory.get/list, the flow catalog, the mcp.apps.* reads) select inline-versus-reference at a pinned 32 KiB bound that does not track this key (D-358).
  • events: driver (inmem / durable); events power the Console's live streaming. Durable persistence is NOT selected on driver — set driver: durable and then pick the backing store with state_driver (sqlite / postgres) + state_dsn. With driver: durable and an empty state_driver the bus loudly degrades to best-effort in-memory (not durable across restart).
  • sessions: idle_ttl (default 24h), hard_cap (default 720h / 30d), sweep_interval; turns.{driver,dsn,retention} (HA-64) wires the durable conversation-turn projection behind sessions.turns.list / .get — empty block = unwired (the routes answer 501); driver inmem / sqlite / postgres, dsn required for sqlite / postgres, retention 0 → the projection's documented default; restart-required.
  • Postgres migration mode: every Postgres-backed store block (state, memory, artifacts, skills, sessions.turns, observability.rollups) accepts migration_mode: apply | verify. Empty defaults to apply, which takes a session advisory lock and therefore uses a direct/session-capable endpoint. After migrations have been applied separately, set the pooled DSN and migration_mode: verify; startup then reads the subsystem's namespaced harbor_schema_migrations and harbor_store_identity plus required physical schema, performs no DDL/transaction/advisory lock, and fails loud when identity, checksums, schema, or an embedded migration is missing or stale. Never point apply at a transaction pool.
  • pauseresume: max_park_duration (ceiling on how long a pause — HITL approval, tool OAuth — may stay parked before the runtime resumes it with the typed timeout decision and the run ends as a constraints-conflict; default 0 = never expire), sweep_interval (sweeper cadence, default 1m).
  • tasks: driver (inprocess or durable). inprocess (default) keeps task/group/patch state in memory — a restart starts empty. durable persists those records through the StateStore so they survive a restart; on open it replays them and recovers any task left running by a crash to failed (code runtime_restarted). It reuses the runtime StateStore, so pair it with a durable state.driver (sqlite / postgres) for cross-process survival; selecting durable with no store wired fails loudly at boot.
  • distributed: bus_driver (loopback or durable) + remote_driver (loopback only in V1.1; A2A wire is post-V1). loopback is in-process; durable persists every BusEnvelope through the StateStore and projects it onto the local event bus, with a poller for cross-instance fan-out + restart-replay (StateStore-backed — Postgres-as-queue on a shared Postgres store; tune with bus_poll_interval). NATS / Redis Streams remain future drivers.

Observed retention horizons are inspection data, not configured retention promises. runtime.health reads only the cached runtime-wide events horizon for every caller. Inspect events, tasks and sessions together through the explicit, audited runtime.retention call with verified admin authority; console:fleet and trust-mode callers cannot use it (D-490).

Validation — the loud loop

harbor validate ./harbor.yaml

Failure modes the validator catches:

  • Required field missing — llm.driver, llm.provider, llm.model, identity.issuer, etc.
  • Type mismatches — memory.budget_tokens: "8000" (string instead of int).
  • Enum violations — memory.strategy: "summary" (not one of none / rolling_summary).
  • Bound violations — governance.identity_tiers.free.budget_ceiling_usd: -1 (negative).
  • Cross-field constraints — memory.driver: sqlite without memory.dsn.

Every error carries the file:line of the offending key. Fix one, re-run, repeat until clean.

Common failure modes

  • harbor validate says unknown field "X". Either a typo (check indentation — YAML is whitespace-sensitive) or the field belongs in a different block. Check docs/CONFIG.md for the canonical block.
  • harbor dev boots but every Protocol call returns 401. Your identity block points at a real IdP but the JWKS isn't reachable. For local dev, use the dev-token flow (see run-the-dev-loop) — the issuer/jwks_url path is for production.
  • harbor dev reboots in an infinite loop. SQLite WAL trap — dsn: inside the project directory. Move it outside.
  • A model swap fails the first call with ErrUnsupportedModel. You forgot to add a model_profiles.<model>.context_window_tokens entry for the new model. There's no silent fallback — add the profile and the call succeeds.

See also

Remote tool brokers now default to verified tenant credential selection. Upgrade the coordinator to the scoped response contract before deploying. An intentionally deployment-wide legacy broker must explicitly declare credential_scope: deployment; signed capability providers always remain tenant scoped. See docs/CONFIG.md, “Tenant-scoped broker credential pulls”, for the request, response and migration.

04 同仓库更多 Skills