Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,19 @@ named rather than smoothed.
## [Unreleased]

### Added
- Run admission control on `delegate_task` (#101). The model may declare
`execution_mode` (`exclusive`, the default, or `parallel_read_only`) and up
to eight normalized `resource_keys` naming what a task touches — a repo
checkout, a deployment target. Two live runs that share a key never overlap
unless both are read-only; the check runs before a run id is minted or an
acceptance record is written, so a refused job burns nothing and can never
surface as `lost`. The refusal is a spoken tool result naming the run in
the way and the shared key, never a hang or a silent queue, and
`check_work` reads out what each running job holds. New knob
`TALK_TRUST_DECLARED_READ_ONLY`, default off: until the operator sets it,
`parallel_read_only` is downgraded to `exclusive` and recorded that way,
because the declaration is the delegating model's own claim, not a
sandbox. A task that names no keys is exactly the task Talk always ran.
- The contributor experience, written down. `CONTRIBUTING.md` now ranks
what we take first (bug fixes on live lanes, then provider and host
compatibility, security hardening, cross-platform, new providers behind
Expand Down
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -516,6 +516,42 @@ silently does less than you asked:
lane that always exists as long as `hermes` is on the PATH.
4. None available — a refusal naming all three missing lanes.

### Two jobs, one checkout — admission control

Delegate two tasks that both touch the same repository and, left alone, they
race: two agents editing one checkout, two deploys to one target. Since #101
the model can say what a task touches, and the run registry refuses the second
job instead of letting it collide:

- `delegate_task` takes two optional arguments. `resource_keys` names up to
eight stable things the task touches — an absolute repo path, a deployment
target, a service name (whitespace-collapsed and case-folded, so two
spellings of one path are one key). `execution_mode` is `exclusive` (the
default) or `parallel_read_only`.
- Two live runs that share any key never overlap unless **both** are
`parallel_read_only`. The check happens before a run id is minted, before
the acceptance record is written, before the worker starts — a refused job
burns nothing and leaves no `lost` record behind.
- A refusal is a spoken tool result naming the run in the way: "run 4 (audit
the repo) is still running and touches the same resource ('/srv/app'); wait
for it, stop it, or re-delegate without that key." Never a hang, never a
silent queue. `check_work` reads out what each running job is holding.
- **`parallel_read_only` is believed only when you say so.** The declaration
is the delegating model's own claim about work it has not done yet — policy
input, not a sandbox — so by default it is downgraded to `exclusive` and
recorded that way. `TALK_TRUST_DECLARED_READ_ONLY=true` lets read-only jobs
on a shared key run together; the knob is read at admission time, so turning
it back off closes every overlap it had allowed. It is the only thing that
can widen behavior.
- No keys means no fence, in either direction: a task that names nothing is
exactly the task Talk always ran, record and all.

The fence is per process and covers the api-server and detached lanes, whose
runs this registry owns. Inside `/talk`, the host's own delegation registry
runs the child: the job is still checked against the keys this registry
holds — never started on top of one — but it holds none itself afterwards,
and its `WORK_STARTED` receipt says so.

### Redirecting work that's already running

Say "tell that audit to focus on the token refresh instead" and `steer_agent`
Expand Down Expand Up @@ -666,6 +702,7 @@ with defaults and failure modes: [docs/OPERATING.md](docs/OPERATING.md#configura
| `TALK_API_SERVER_URL` | `http://127.0.0.1:8642` | Where the api-server lane looks |
| `TALK_API_SERVER_KEY` | `API_SERVER_KEY` | Key for the api-server lane (blank = send none) |
| `TALK_AGENT_TIMEOUT_S` | `1800` | Budget for one background run, and its watcher |
| `TALK_TRUST_DECLARED_READ_ONLY` | `false` | Believe a delegated task's `parallel_read_only` declaration, letting read-only runs share a `resource_key`; off downgrades every run to `exclusive` |
| `TALK_IDENTITY_INCLUDE` | all | Which identity sections ride the prompt |
| `TALK_MEMORY_SEARCH_TIMEOUT_S` | `10.0` | Wait bound for the in-process remembered-context (Honcho) lookup |
| `TALK_SESSION_KEY` | unset | Stable operator scope sent as `X-Hermes-Session-Key` on api-server runs, so host-side memory survives `/clear` (blank = send none). **Not a session boundary: every voice-channel participant shares this scope** — memory reads are not gated by the operator ledger, so do not set it in multi-user channels until per-speaker scoping lands |
Expand Down
1 change: 1 addition & 0 deletions docs/OPERATING.md
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,7 @@ allowlist returns a non-sensitive spoken denial without running the handler.
|---|---|---|
| `TALK_AGENT_PROFILE` | auto-detect | Hermes profile for the detached spawn. **Set-but-blank = explicit opt-out** (never pass `--profile`). Full story: [README](../README.md#talk_agent_profile--which-profile-the-background-agent-runs-under). |
| `TALK_AGENT_TIMEOUT_S` | `1800` | Wall-clock budget for one background run and its watcher. Junk or ≤0 silently takes the default. |
| `TALK_TRUST_DECLARED_READ_ONLY` | `false` | Whether a delegated task's `parallel_read_only` declaration is believed (hermes-talk#101). Off (the default, and anything other than `1`/`true`/`yes`/`on`): every run is admitted as `exclusive`, so two runs sharing a `resource_key` never overlap. On: read-only runs may share a key; a read-only run still never overlaps an exclusive holder. Read at admission time — turning it off closes overlaps admitted earlier. The only knob that can widen behavior; the [README](../README.md#two-jobs-one-checkout--admission-control) has the model-facing contract. |
| `TALK_APPROVAL_PROMPT_TIMEOUT_S` | `60.0` | How long a spoken approval question (the capability bridge) stays open before it resolves as **deny** — fail closed, silence is not consent. Sized under the host's own approval wait (300s) so the voice lane's deny lands first and the run unwinds on the operator's answer-or-silence. Junk or ≤0 silently takes the default. |
| `TALK_CATALOG_STARTUP_WAIT_S` | `2.5` | Bounded head start a session start gives the first capability-catalog read, so a cold process still mints the live-catalog prompt section deterministically. `0` is honored and disables the wait (fire-and-forget); on expiry the session starts with the section omitted — logged, never a stall. Junk or negative silently takes the default. |
| `TALK_MEMORY_SEARCH_TIMEOUT_S` | `10.0` | Wait bound for the in-process remembered-context (Honcho) tier of `search_memory`. On timeout the model speaks a retryable failure instead of the tool pipeline blocking; the transcript tier (`session_search`, a local FTS5 read) is not bounded. Junk or ≤0 silently takes the default. |
Expand Down
16 changes: 16 additions & 0 deletions talk_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -870,6 +870,21 @@ def approval_prompt_timeout_s() -> float:
)


def trust_declared_read_only() -> bool:
"""Whether a model's ``parallel_read_only`` declaration is believed.

Default **off** (hermes-talk#101): the declaration is the delegating
model's own claim about work it has not done yet — policy input, not a
sandbox — so by default every delegated run is treated as ``exclusive``
and two runs sharing a resource key never overlap. Only an explicit
``TALK_TRUST_DECLARED_READ_ONLY=true`` lets read-only runs on a shared
key run together; anything else, junk included, keeps the fence.
"""

raw = (os.environ.get("TALK_TRUST_DECLARED_READ_ONLY") or "").strip().lower()
return raw in {"1", "true", "yes", "on"}


__all__ = [
"DEFAULT_AGENT_TIMEOUT_S",
"DEFAULT_API_SERVER_POLL_S",
Expand Down Expand Up @@ -936,5 +951,6 @@ def approval_prompt_timeout_s() -> float:
"talk_model",
"talk_provider",
"talk_voice",
"trust_declared_read_only",
"voice_mode",
]
77 changes: 67 additions & 10 deletions talk_host.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,15 @@ def _catalog_from_host_modules() -> dict | None:
#: aloud on every such answer.
REMEMBERED_PREFIX = "from remembered context: "

#: Appended to a tier-1 receipt when the model declared resource keys
#: (hermes-talk#101). The host's own delegation registry ran the child and
#: this plugin's run registry does not hold it, so a later run naming the
#: same key would not be fenced against it — said rather than assumed.
HOST_LOOP_ADMISSION_NOTE = (
" (this lane runs inside Hermes's own delegation registry, which does "
"not hold resource keys — the fence applies to api-server and detached runs)"
)

MAX_TOOL_OUTPUT_CHARS = 2_000


Expand Down Expand Up @@ -1022,7 +1031,14 @@ def _search_memory_via_api_server(self, query: str, limit: int) -> str:
"Hermes agent through the api server; I'll tell you what it finds."
)

def run_agent(self, prompt: str, background: bool = True) -> str:
def run_agent(
self,
prompt: str,
background: bool = True,
*,
execution_mode: str | None = None,
resource_keys: Any = None,
) -> str:
"""Hand a self-contained task to a background Hermes agent.

Four backends, tried in order, and every fall-through is ANNOUNCED in
Expand All @@ -1044,10 +1060,23 @@ def run_agent(self, prompt: str, background: bool = True) -> str:
``background`` is accepted for the caller's mental model but never
forwarded: Hermes documents the tool's own flag as deprecated and
ignored, and every real backend is asynchronous regardless.

``execution_mode`` / ``resource_keys`` are the admission declaration
(hermes-talk#101), enforced by the run registry on tiers 2 and 3. Tier
1 hands the child to Hermes's own delegation registry, which this
plugin does not own: it is still checked against the keys the run
registry holds — never started on top of one — but it holds none
itself afterwards, and the receipt says so.
"""

ctx = get_ctx()
if ctx is not None:
try:
talk_runs.check_admission(execution_mode, resource_keys)
except talk_runs.AdmissionRefused as exc:
return f"I can't start that yet — {exc}."
except ValueError as exc:
return f"I couldn't start that work: {exc}"
try:
raw = ctx.dispatch_tool(DELEGATE_TOOL_NAME, {"goal": prompt})
except Exception as exc: # noqa: BLE001 — the model speaks the failure
Expand All @@ -1058,14 +1087,27 @@ def run_agent(self, prompt: str, background: bool = True) -> str:
# A host refusal (paused delegation, depth limit) must
# never ride behind a WORK_STARTED prefix.
return f"I couldn't start that work — {spoken}"
return f"WORK_STARTED — {spoken}"
started = f"WORK_STARTED — {spoken}"
if talk_runs.normalize_resource_keys(resource_keys):
started += HOST_LOOP_ADMISSION_NOTE
return started

via_api_server = self._run_api_server_agent(prompt)
via_api_server = self._run_api_server_agent(
prompt, execution_mode=execution_mode, resource_keys=resource_keys
)
if via_api_server is not None:
return via_api_server
return self._run_detached_agent(prompt)
return self._run_detached_agent(
prompt, execution_mode=execution_mode, resource_keys=resource_keys
)

def _run_api_server_agent(self, prompt: str) -> str | None:
def _run_api_server_agent(
self,
prompt: str,
*,
execution_mode: str | None = None,
resource_keys: Any = None,
) -> str | None:
"""Tier 2: run the task on a real agent over the api_server.

``None`` means the lane is unavailable and the caller should fall
Expand All @@ -1077,9 +1119,13 @@ def _run_api_server_agent(self, prompt: str) -> str | None:
label = prompt.strip()[:60]
try:
run_id = talk_runs.start_run(
"agent", label, _api_server_worker(prompt, session_id=None)
"agent",
label,
_api_server_worker(prompt, session_id=None),
execution_mode=execution_mode,
resource_keys=resource_keys,
)
except talk_runs.RoutingUnavailable as exc:
except (talk_runs.RoutingUnavailable, talk_runs.AdmissionRefused) as exc:
return f"I can't start that yet — {exc}."
except Exception as exc: # noqa: BLE001 — the model speaks the failure
return f"I couldn't start that work: {type(exc).__name__}: {exc}"
Expand All @@ -1088,7 +1134,13 @@ def _run_api_server_agent(self, prompt: str) -> str | None:
"Hermes agent through the api server; I'll tell you when it lands."
)

def _run_detached_agent(self, prompt: str) -> str:
def _run_detached_agent(
self,
prompt: str,
*,
execution_mode: str | None = None,
resource_keys: Any = None,
) -> str:
"""Tier 3/4: run the task as a detached ``hermes -z`` one-shot."""

binary = hermes_binary()
Expand All @@ -1101,9 +1153,13 @@ def _run_detached_agent(self, prompt: str) -> str:
label = prompt.strip()[:60]
try:
run_id = talk_runs.start_run(
"agent", label, _detached_agent_worker(prompt, binary)
"agent",
label,
_detached_agent_worker(prompt, binary),
execution_mode=execution_mode,
resource_keys=resource_keys,
)
except talk_runs.RoutingUnavailable as exc:
except (talk_runs.RoutingUnavailable, talk_runs.AdmissionRefused) as exc:
return f"I can't start that yet — {exc}."
except Exception as exc: # noqa: BLE001 — the model speaks the failure
return f"I couldn't start that work: {type(exc).__name__}: {exc}"
Expand Down Expand Up @@ -1827,6 +1883,7 @@ def host() -> HostAdapter:
"AGENT_LOOP_ABSENT_MARKERS",
"DELEGATE_TOOL_NAME",
"HERMES_BINARY",
"HOST_LOOP_ADMISSION_NOTE",
"LANE_API_SERVER",
"LANE_ATTACHED",
"LANE_NONE",
Expand Down
Loading