Fix Hermes adapter integration and recovery
This commit is contained in:
+51
-32
@@ -59,7 +59,7 @@ Every error uses one shape with a machine-readable code:
|
|||||||
Codes: `unauthorized`, `auth_timeout`, `version_mismatch`, `bad_request`,
|
Codes: `unauthorized`, `auth_timeout`, `version_mismatch`, `bad_request`,
|
||||||
`invalid_json`, `unknown_type`, `not_found`, `conversation_not_found`,
|
`invalid_json`, `unknown_type`, `not_found`, `conversation_not_found`,
|
||||||
`approval_not_found`, `clarify_not_found`, `too_large`, `rate_limited`,
|
`approval_not_found`, `clarify_not_found`, `too_large`, `rate_limited`,
|
||||||
`internal_error`, `not_implemented`.
|
`run_active`, `internal_error`, `not_implemented`.
|
||||||
|
|
||||||
Limits: chat text ≤ 64,000 chars; inbound WS frame ≤ 2 MiB (violations get
|
Limits: chat text ≤ 64,000 chars; inbound WS frame ≤ 2 MiB (violations get
|
||||||
`too_large`); conversation history fetch ≤ 500 messages.
|
`too_large`); conversation history fetch ≤ 500 messages.
|
||||||
@@ -98,13 +98,20 @@ Limits: chat text ≤ 64,000 chars; inbound WS frame ≤ 2 MiB (violations get
|
|||||||
← { "type": "conversation.history", "conversation_id": "a1b2…", "request_id": "r3",
|
← { "type": "conversation.history", "conversation_id": "a1b2…", "request_id": "r3",
|
||||||
"messages": [
|
"messages": [
|
||||||
{ "message_id": "m12", "role": "user", "text": "hey", "ts": "…|null" },
|
{ "message_id": "m12", "role": "user", "text": "hey", "ts": "…|null" },
|
||||||
{ "message_id": "m13", "role": "assistant", "text": "hi!", "ts": "…|null" } ] }
|
{ "message_id": "m13", "role": "assistant", "text": "hi!", "ts": "…|null" } ],
|
||||||
|
"attachments": [],
|
||||||
|
"run": null,
|
||||||
|
"tools": [],
|
||||||
|
"approvals": [],
|
||||||
|
"clarifications": [] }
|
||||||
```
|
```
|
||||||
|
|
||||||
History is the authoritative Hermes transcript (`role` is always `user` or
|
History is the authoritative Hermes transcript (`role` is always `user` or
|
||||||
`assistant`). On reconnect, re-open the last-open conversations and resume —
|
`assistant`). The other fields form a recoverable snapshot: unexpired
|
||||||
no client-side message cache is needed for correctness. Unknown conversation
|
attachments, the active run (if any), latest structured tool states, and
|
||||||
→ `conversation_not_found` error.
|
pending approval/clarification requests. On reconnect, replace local state
|
||||||
|
with this snapshot, then consume new live events. Unknown conversation →
|
||||||
|
`conversation_not_found` error.
|
||||||
|
|
||||||
### `conversation.rename`
|
### `conversation.rename`
|
||||||
|
|
||||||
@@ -168,14 +175,15 @@ Run end:
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
← { "type": "run.finished", "conversation_id": "a1b2…", "run_id": "8c1f…",
|
← { "type": "run.finished", "conversation_id": "a1b2…", "run_id": "8c1f…",
|
||||||
"status": "completed" | "cancelled" | "failed" | "idle",
|
"status": "completed" | "cancelled" | "failed",
|
||||||
"error": "only on failure" }
|
"error": "only on failure" }
|
||||||
```
|
```
|
||||||
|
|
||||||
State machine per assistant turn:
|
State machine per assistant turn:
|
||||||
`run.accepted → message.start → (message.delta)* → message.complete → run.finished`.
|
`run.accepted → message.start → (message.delta)* → message.complete → run.finished`.
|
||||||
A turn with no streaming skips `message.start`/`message.delta`. Never infer
|
A non-streaming turn may skip `message.delta`; `message.start` is always sent
|
||||||
state from text — use these events.
|
when the run is accepted. Only one run may be active per conversation;
|
||||||
|
another `chat.send` receives `run_active`. Never infer state from text.
|
||||||
|
|
||||||
### `run.cancel` — stop an active run
|
### `run.cancel` — stop an active run
|
||||||
|
|
||||||
@@ -186,8 +194,8 @@ state from text — use these events.
|
|||||||
|
|
||||||
Cancellation uses Hermes's supported interrupt mechanism (agent interrupt +
|
Cancellation uses Hermes's supported interrupt mechanism (agent interrupt +
|
||||||
run-generation invalidation) — the conversation stays consistent and
|
run-generation invalidation) — the conversation stays consistent and
|
||||||
resumable. Cancelling with no active run returns `run.finished`
|
resumable. A stale `run_id`, missing run, or run that Hermes can no longer
|
||||||
`status:"idle"`.
|
interrupt returns `not_found`; success emits exactly one cancelled event.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -200,21 +208,20 @@ Tool activity arrives as `tool.event` messages, completely separate from
|
|||||||
```json
|
```json
|
||||||
{ "type": "tool.event",
|
{ "type": "tool.event",
|
||||||
"conversation_id": "a1b2…",
|
"conversation_id": "a1b2…",
|
||||||
|
"run_id": "8c1f…",
|
||||||
"tool_call_id": "t-1a2b3c4d5e6f",
|
"tool_call_id": "t-1a2b3c4d5e6f",
|
||||||
"tool_name": "web_search",
|
"tool_name": "web_search",
|
||||||
"status": "running" | "completed" | "failed",
|
"status": "running" | "completed" | "failed" | "cancelled",
|
||||||
"description": "cats — short preview from the agent (may be null)",
|
|
||||||
"args_redacted": { "query": "cats" }, // only on "running"; secret-looking keys redacted
|
"args_redacted": { "query": "cats" }, // only on "running"; secret-looking keys redacted
|
||||||
"duration_ms": 1234, // only on completion/failure (may be null)
|
"duration_ms": 1234, // only on completion/failure (may be null)
|
||||||
"error": "only on failed, truncated", // only on "failed"
|
"error": "only on failed, truncated", // only on "failed"
|
||||||
"ts": "..." }
|
"ts": "..." }
|
||||||
```
|
```
|
||||||
|
|
||||||
Correlate `running` → `completed`/`failed` by `tool_call_id`. Note: the
|
Correlate `running` → `completed`/`failed` by `tool_call_id`. Both events use
|
||||||
running event's ID comes from the adapter and the completion event from
|
Hermes's authoritative call ID from the pre/post tool hooks and include the
|
||||||
Hermes's `post_tool_call` hook; when they differ, correlate by
|
active `run_id` when one exists. Secret-looking argument keys are redacted
|
||||||
`(tool_name, conversation)` as a fallback and prefer the completion event's
|
recursively before leaving the server.
|
||||||
ID going forward.
|
|
||||||
|
|
||||||
No fake "Searching the web…" text is ever injected into `message.*` events.
|
No fake "Searching the web…" text is ever injected into `message.*` events.
|
||||||
|
|
||||||
@@ -228,6 +235,8 @@ When Hermes pauses for a human decision on a dangerous action:
|
|||||||
{ "type": "approval.request",
|
{ "type": "approval.request",
|
||||||
"approval_id": "3d4e5f6070a1",
|
"approval_id": "3d4e5f6070a1",
|
||||||
"session_key": "agent:main:pheby:dm:a1b2…",
|
"session_key": "agent:main:pheby:dm:a1b2…",
|
||||||
|
"conversation_id": "a1b2…",
|
||||||
|
"run_id": "8c1f…",
|
||||||
"command": "rm -rf /tmp/build-output",
|
"command": "rm -rf /tmp/build-output",
|
||||||
"description": "Destructive shell command (rm -rf)",
|
"description": "Destructive shell command (rm -rf)",
|
||||||
"choices": ["once", "session", "always", "deny"],
|
"choices": ["once", "session", "always", "deny"],
|
||||||
@@ -240,11 +249,13 @@ Respond:
|
|||||||
→ { "type": "approval.respond", "approval_id": "3d4e5f6070a1",
|
→ { "type": "approval.respond", "approval_id": "3d4e5f6070a1",
|
||||||
"choice": "once" | "session" | "always" | "deny",
|
"choice": "once" | "session" | "always" | "deny",
|
||||||
"reason": "optional free text with deny", "request_id": "r8" }
|
"reason": "optional free text with deny", "request_id": "r8" }
|
||||||
← { "type": "approval.resolved", "approval_id": "3d4e5f6070a1", "choice": "once", "request_id": "r8" }
|
← { "type": "approval.resolved", "approval_id": "3d4e5f6070a1",
|
||||||
// broadcast confirmation (also informs other tabs):
|
"choice": "once", "accepted": true, "request_id": "r8" }
|
||||||
← { "type": "approval.resolved", "approval_id": "3d4e5f6070a1", "choice": "once", "accepted": true }
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The single resolution event is broadcast to every connected client; the
|
||||||
|
responding client's `request_id` is included on that broadcast.
|
||||||
|
|
||||||
Choices map to Hermes semantics: `once` (approve this action), `session`
|
Choices map to Hermes semantics: `once` (approve this action), `session`
|
||||||
(approve pattern for this conversation), `always` (also persist), `deny`
|
(approve pattern for this conversation), `always` (also persist), `deny`
|
||||||
(decline; the agent is told NOT to retry). Unknown/stale ID →
|
(decline; the agent is told NOT to retry). Unknown/stale ID →
|
||||||
@@ -259,6 +270,8 @@ own timeout, so a silently-closed socket can't leave a zombie gate.
|
|||||||
{ "type": "clarify.request",
|
{ "type": "clarify.request",
|
||||||
"clarify_id": "c1a2b3d4e5",
|
"clarify_id": "c1a2b3d4e5",
|
||||||
"session_key": "agent:main:pheby:dm:a1b2…",
|
"session_key": "agent:main:pheby:dm:a1b2…",
|
||||||
|
"conversation_id": "a1b2…",
|
||||||
|
"run_id": "8c1f…",
|
||||||
"question": "Deploy to staging or production?",
|
"question": "Deploy to staging or production?",
|
||||||
"choices": ["staging", "production"], // null ⇒ free text only
|
"choices": ["staging", "production"], // null ⇒ free text only
|
||||||
"allow_free_text": true,
|
"allow_free_text": true,
|
||||||
@@ -269,13 +282,13 @@ Respond (either a choice value or free text):
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
→ { "type": "clarify.respond", "clarify_id": "c1a2b3d4e5", "response": "production", "request_id": "r9" }
|
→ { "type": "clarify.respond", "clarify_id": "c1a2b3d4e5", "response": "production", "request_id": "r9" }
|
||||||
← { "type": "clarify.resolved", "clarify_id": "c1a2b3d4e5", "request_id": "r9" }
|
← { "type": "clarify.resolved", "clarify_id": "c1a2b3d4e5",
|
||||||
← { "type": "clarify.resolved", "clarify_id": "c1a2b3d4e5", "accepted": true } // broadcast
|
"accepted": true, "request_id": "r9" }
|
||||||
```
|
```
|
||||||
|
|
||||||
`accepted:false` on the broadcast means Hermes had already resolved/timed out
|
The single resolution event is broadcast. A stale/timed-out ID instead gets
|
||||||
the prompt. Always render an "Other" affordance — Hermes clarifications
|
`clarify_not_found`. Always render an "Other" affordance — Hermes
|
||||||
accept free text.
|
clarifications accept free text.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -330,7 +343,7 @@ Authorization: Bearer <PHEBY_SECRET> (or ApiKey <secret>, or X-Pheby-Secre
|
|||||||
### List providers + models
|
### List providers + models
|
||||||
|
|
||||||
```json
|
```json
|
||||||
→ { "type": "models.list", "request_id": "r10" }
|
→ { "type": "models.list", "conversation_id": "a1b2…", "request_id": "r10" }
|
||||||
← { "type": "models.snapshot", "request_id": "r10",
|
← { "type": "models.snapshot", "request_id": "r10",
|
||||||
"providers": [
|
"providers": [
|
||||||
{ "slug": "openrouter", "name": "OpenRouter", "is_current": true,
|
{ "slug": "openrouter", "name": "OpenRouter", "is_current": true,
|
||||||
@@ -343,13 +356,17 @@ Authorization: Bearer <PHEBY_SECRET> (or ApiKey <secret>, or X-Pheby-Secre
|
|||||||
```
|
```
|
||||||
|
|
||||||
Lists come from Hermes's own credential-aware picker data — nothing is
|
Lists come from Hermes's own credential-aware picker data — nothing is
|
||||||
hardcoded. Models are exactly what the configured providers expose.
|
hardcoded. Models are exactly what the configured providers expose. The
|
||||||
|
optional `conversation_id` makes `current_model`, `current_provider`, and
|
||||||
|
`scope` reflect that conversation's override.
|
||||||
|
|
||||||
### Read / change current model
|
### Read / change current model
|
||||||
|
|
||||||
```json
|
```json
|
||||||
→ { "type": "models.current", "request_id": "r11" }
|
→ { "type": "models.current", "conversation_id": "a1b2…", "request_id": "r11" }
|
||||||
← { "type": "model.current", "request_id": "r11", "model": "z-ai/glm-5.3-flash", "provider": "openrouter", "ts": "..." }
|
← { "type": "model.current", "request_id": "r11", "model": "z-ai/glm-5.3-flash",
|
||||||
|
"provider": "openrouter", "scope": "conversation",
|
||||||
|
"conversation_id": "a1b2…", "ts": "..." }
|
||||||
|
|
||||||
→ { "type": "model.set", "model": "anthropic/claude-sonnet-4",
|
→ { "type": "model.set", "model": "anthropic/claude-sonnet-4",
|
||||||
"provider": "anthropic", // optional
|
"provider": "anthropic", // optional
|
||||||
@@ -369,10 +386,11 @@ conversation the default moved.
|
|||||||
## Reasoning effort
|
## Reasoning effort
|
||||||
|
|
||||||
```json
|
```json
|
||||||
→ { "type": "reasoning.current", "request_id": "r13" }
|
→ { "type": "reasoning.current", "conversation_id": "a1b2…", "request_id": "r13" }
|
||||||
← { "type": "reasoning.snapshot", "request_id": "r13",
|
← { "type": "reasoning.snapshot", "request_id": "r13",
|
||||||
"effort": "medium", // current effective effort (may be null = provider default)
|
"effort": "medium", // current effective effort (may be null = provider default)
|
||||||
"enabled": true, // false ⇒ thinking disabled
|
"enabled": true, // false ⇒ thinking disabled
|
||||||
|
"scope": "conversation", "conversation_id": "a1b2…",
|
||||||
"supported_efforts": ["none","minimal","low","medium","high","xhigh","max","ultra"],
|
"supported_efforts": ["none","minimal","low","medium","high","xhigh","max","ultra"],
|
||||||
"ts": "..." }
|
"ts": "..." }
|
||||||
|
|
||||||
@@ -399,8 +417,9 @@ notifications) is pushed as normal `message.*` / `run.*` events even when it
|
|||||||
is not a reply to your last request. Reconnection procedure for clients:
|
is not a reply to your last request. Reconnection procedure for clients:
|
||||||
|
|
||||||
1. Reconnect WS, redo `hello`.
|
1. Reconnect WS, redo `hello`.
|
||||||
2. Re-`conversation.open` the conversations you show; replace local state
|
2. Re-`conversation.open` the conversations you show; replace local history,
|
||||||
with `conversation.history` (authoritative).
|
attachments, run/tool state, and pending decisions with its authoritative
|
||||||
|
snapshot.
|
||||||
3. Re-`models.current` / `reasoning.current` if those views are visible.
|
3. Re-`models.current` / `reasoning.current` if those views are visible.
|
||||||
4. Live events continue from there.
|
4. Live events continue from there.
|
||||||
|
|
||||||
|
|||||||
+27
-27
@@ -29,7 +29,7 @@ Pheby plugin (in Hermes gateway process)
|
|||||||
├─ aiohttp server: GET /ws, GET /attachments/{id}, GET /health
|
├─ aiohttp server: GET /ws, GET /attachments/{id}, GET /health
|
||||||
├─ protocol layer: JSON message types, auth, limits, errors
|
├─ protocol layer: JSON message types, auth, limits, errors
|
||||||
├─ PhebyAdapter (BasePlatformAdapter subclass)
|
├─ PhebyAdapter (BasePlatformAdapter subclass)
|
||||||
│ outbound: send/edit → chat events, format_tool_event → tool.event,
|
│ outbound: send/edit → chat events, tool hooks → tool.event,
|
||||||
│ send_clarify → clarify.request, send_document/… → attachments
|
│ send_clarify → clarify.request, send_document/… → attachments
|
||||||
│ inbound: WS messages → MessageEvent → gateway pipeline
|
│ inbound: WS messages → MessageEvent → gateway pipeline
|
||||||
└─ hermes_bridge: sessions, approvals (tools.approval),
|
└─ hermes_bridge: sessions, approvals (tools.approval),
|
||||||
@@ -44,9 +44,12 @@ Key properties:
|
|||||||
- **Hermes is authoritative.** Conversations are Hermes sessions keyed
|
- **Hermes is authoritative.** Conversations are Hermes sessions keyed
|
||||||
`agent:main:pheby:dm:<conversation_id>`; history/titles live in Hermes
|
`agent:main:pheby:dm:<conversation_id>`; history/titles live in Hermes
|
||||||
`state.db`. Pheby keeps only a thin, rebuildable name index.
|
`state.db`. Pheby keeps only a thin, rebuildable name index.
|
||||||
- **The full gateway pipeline works unchanged** — auth/pairing, tool
|
- **The full gateway pipeline works unchanged** — tool
|
||||||
approval, clarify, deliverables, streaming, cron delivery — because inbound
|
approval, clarify, deliverables, streaming, cron delivery — because inbound
|
||||||
messages are ordinary `MessageEvent`s on a registered platform.
|
messages are ordinary `MessageEvent`s on a registered platform. The adapter
|
||||||
|
marks its authenticated WebSocket as the upstream authorization boundary,
|
||||||
|
so a valid Pheby secret is not rejected by Hermes's separate messaging-
|
||||||
|
platform allowlist layer.
|
||||||
- **Single-user, shared-secret.** One `PHEBY_SECRET` gates every WS
|
- **Single-user, shared-secret.** One `PHEBY_SECRET` gates every WS
|
||||||
connection and attachment download (constant-time compare). No accounts,
|
connection and attachment download (constant-time compare). No accounts,
|
||||||
no registration — but IDs and message shapes are multi-client friendly.
|
no registration — but IDs and message shapes are multi-client friendly.
|
||||||
@@ -99,8 +102,7 @@ platforms:
|
|||||||
|
|
||||||
Env vars override YAML: `PHEBY_SECRET`, `PHEBY_BIND_HOST`, `PHEBY_PORT`,
|
Env vars override YAML: `PHEBY_SECRET`, `PHEBY_BIND_HOST`, `PHEBY_PORT`,
|
||||||
`PHEBY_DEBUG`, `PHEBY_LOG_CHAT_CONTENT`. Optional: `PHEBY_HOME_CHANNEL`
|
`PHEBY_DEBUG`, `PHEBY_LOG_CHAT_CONTENT`. Optional: `PHEBY_HOME_CHANNEL`
|
||||||
(conversation ID receiving cron deliveries), `PHEBY_ALLOWED_USERS`,
|
(conversation ID receiving cron deliveries).
|
||||||
`PHEBY_ALLOW_ALL_USERS`.
|
|
||||||
|
|
||||||
### 4. Start Hermes with the gateway
|
### 4. Start Hermes with the gateway
|
||||||
|
|
||||||
@@ -134,7 +136,7 @@ pheby.example.com {
|
|||||||
|
|
||||||
# WebSocket + API
|
# WebSocket + API
|
||||||
reverse_proxy /ws 127.0.0.1:8620
|
reverse_proxy /ws 127.0.0.1:8620
|
||||||
reverse_proxy /attachments 127.0.0.1:8620
|
reverse_proxy /attachments/* 127.0.0.1:8620
|
||||||
reverse_proxy /health 127.0.0.1:8620
|
reverse_proxy /health 127.0.0.1:8620
|
||||||
|
|
||||||
# Optionally restrict by source when on a public VPS:
|
# Optionally restrict by source when on a public VPS:
|
||||||
@@ -153,10 +155,10 @@ pheby.example.com {
|
|||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- Caddy provides HTTPS + automatic certificates; the plugin never sees TLS.
|
- Caddy provides HTTPS + automatic certificates; the plugin never sees TLS.
|
||||||
- `X-Forwarded-For` is not used for auth decisions (the lockout key is the
|
- Authentication always depends on the shared secret. For lockout accounting,
|
||||||
direct peer address — behind Caddy that is Caddy itself, so lockout is
|
`X-Forwarded-For` is accepted only when the direct peer is loopback (the
|
||||||
effectively global; that is acceptable for a single-user deployment and
|
documented local-Caddy setup). A directly exposed non-loopback client cannot
|
||||||
still stops brute force).
|
spoof that header to evade rate limiting.
|
||||||
- The Android client connects to `wss://pheby.example.com/ws` and downloads
|
- The Android client connects to `wss://pheby.example.com/ws` and downloads
|
||||||
attachments from `https://pheby.example.com/attachments/{id}` with
|
attachments from `https://pheby.example.com/attachments/{id}` with
|
||||||
`Authorization: Bearer <PHEBY_SECRET>`.
|
`Authorization: Bearer <PHEBY_SECRET>`.
|
||||||
@@ -243,23 +245,21 @@ around. None require core modifications; all are handled cleanly.
|
|||||||
valid effort levels. Pheby exposes Hermes's canonical level set
|
valid effort levels. Pheby exposes Hermes's canonical level set
|
||||||
(`none, minimal, low, medium, high, xhigh, max, ultra`) and documents that
|
(`none, minimal, low, medium, high, xhigh, max, ultra`) and documents that
|
||||||
an unsupported level surfaces as a provider error on the next turn.
|
an unsupported level surfaces as a provider error on the next turn.
|
||||||
3. **Conversation delete is a documented approximation.** Hermes's
|
3. **Conversation routing deletion uses a private Hermes detail.** Hermes's
|
||||||
SessionStore has no public per-routing-key delete; Pheby deletes the
|
SessionStore has no public per-routing-key delete. Pheby deletes the
|
||||||
authoritative transcript row (`SessionDB.delete_session`) and resets the
|
authoritative transcript through `SessionDB.delete_session`, then removes
|
||||||
routing entry, which yields the same user-visible behavior.
|
the exact routing entry under SessionStore's own lock/save discipline. It
|
||||||
4. **Tool `running` → `completed` ID correlation.** Start events get
|
does not call `reset_session` (which would recreate the deleted chat). This
|
||||||
adapter-generated IDs; completion events carry Hermes's authoritative
|
is isolated in `hermes_bridge.py` but may need adjustment after a Hermes
|
||||||
`tool_call_id` from the `post_tool_call` hook. The protocol documents
|
SessionStore refactor.
|
||||||
correlating by `(tool_name, conversation)` when IDs differ. (Gateway
|
4. **Standalone cron delivery.** Cron jobs targeting `pheby` are delivered
|
||||||
tool-start events don't carry Hermes's call ID yet.)
|
|
||||||
5. **Standalone cron delivery.** Cron jobs targeting `pheby` are delivered
|
|
||||||
in-process with the gateway. A `standalone_sender_fn` hook exists but
|
in-process with the gateway. A `standalone_sender_fn` hook exists but
|
||||||
cannot push to a WS server it isn't hosting; out-of-process cron delivery
|
cannot push to a WS server it isn't hosting; out-of-process cron delivery
|
||||||
to Pheby is not supported (documented, fail-loud).
|
to Pheby is not supported (documented, fail-loud).
|
||||||
6. **Reconnect recovery is state-based, not event-replay.** Missed events are
|
5. **Reconnect recovery is state-based, not event-replay.** Re-opening a
|
||||||
recovered by re-opening conversations (authoritative history), not by
|
conversation returns authoritative history plus unexpired attachments and
|
||||||
replaying a server-side event log. This is the spec's preferred approach
|
the current run, tool, approval, and clarification snapshot. The server
|
||||||
and keeps the protocol simple.
|
does not retain a replay log of every transient delta.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -270,9 +270,9 @@ python3 -m venv .venv && .venv/bin/pip install pytest pytest-asyncio aiohttp pyy
|
|||||||
.venv/bin/python -m pytest tests/ -o addopts= -q --asyncio-mode=auto
|
.venv/bin/python -m pytest tests/ -o addopts= -q --asyncio-mode=auto
|
||||||
```
|
```
|
||||||
|
|
||||||
Tests are gateway-free (fakes; real Hermes primitives exercised in-process
|
The 46 tests are gateway-free (fakes; real Hermes primitives exercised
|
||||||
where safe — no LLM calls). Two live smoke tests bind an ephemeral localhost
|
in-process where safe — no LLM calls). Two live smoke tests bind an ephemeral
|
||||||
port.
|
localhost port.
|
||||||
|
|
||||||
```
|
```
|
||||||
repo layout
|
repo layout
|
||||||
|
|||||||
@@ -9,12 +9,11 @@ set ``PHEBY_SECRET`` in ``~/.hermes/.env``, and restart the gateway.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
__version__ = "1.0.0"
|
__version__ = "1.0.1"
|
||||||
|
|
||||||
|
|
||||||
def register(ctx: Any) -> None:
|
def register(ctx: Any) -> None:
|
||||||
@@ -47,8 +46,6 @@ def register(ctx: Any) -> None:
|
|||||||
env_enablement_fn=env_enablement,
|
env_enablement_fn=env_enablement,
|
||||||
# Home channel for cron / notification delivery when configured.
|
# Home channel for cron / notification delivery when configured.
|
||||||
cron_deliver_env_var="PHEBY_HOME_CHANNEL",
|
cron_deliver_env_var="PHEBY_HOME_CHANNEL",
|
||||||
allowed_users_env="PHEBY_ALLOWED_USERS",
|
|
||||||
allow_all_env="PHEBY_ALLOW_ALL_USERS",
|
|
||||||
emoji="🐱",
|
emoji="🐱",
|
||||||
pii_safe=True, # single-user private platform; no PII in routing IDs
|
pii_safe=True, # single-user private platform; no PII in routing IDs
|
||||||
allow_update_command=True,
|
allow_update_command=True,
|
||||||
@@ -61,15 +58,21 @@ def register(ctx: Any) -> None:
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# post_tool_call observer → structured tool-result events. Registered
|
# Tool observers provide authoritative session_id + tool_call_id values.
|
||||||
# against the plugin context so it loads with the plugin, before any
|
# The adapter's presentation-only format_tool_event hook deliberately eats
|
||||||
# adapter is constructed (the hook is a no-op until the adapter serves).
|
# Hermes's generic tool chrome to avoid duplicate/ambiguous events.
|
||||||
|
def _pre_tool_call(**kwargs: Any) -> None:
|
||||||
|
adapter = adapter_holder.get("adapter")
|
||||||
|
if adapter is not None:
|
||||||
|
adapter.on_pre_tool_call(**kwargs)
|
||||||
|
|
||||||
def _post_tool_call(**kwargs: Any) -> None:
|
def _post_tool_call(**kwargs: Any) -> None:
|
||||||
adapter = adapter_holder.get("adapter")
|
adapter = adapter_holder.get("adapter")
|
||||||
if adapter is not None:
|
if adapter is not None:
|
||||||
adapter.on_post_tool_call(**kwargs)
|
adapter.on_post_tool_call(**kwargs)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
ctx.register_hook("pre_tool_call", _pre_tool_call)
|
||||||
ctx.register_hook("post_tool_call", _post_tool_call)
|
ctx.register_hook("post_tool_call", _post_tool_call)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("[pheby] post_tool_call hook registration failed",
|
logger.debug("[pheby] post_tool_call hook registration failed",
|
||||||
|
|||||||
+183
-147
@@ -6,7 +6,7 @@ clarify, deliverables, streaming — works unchanged on the Pheby platform.
|
|||||||
|
|
||||||
Outbound mapping:
|
Outbound mapping:
|
||||||
* ``send`` / ``edit_message`` → chat draft events (S_MESSAGE_DELTA etc.)
|
* ``send`` / ``edit_message`` → chat draft events (S_MESSAGE_DELTA etc.)
|
||||||
* ``format_tool_event`` → structured S_TOOL_EVENT JSON (never fake text)
|
* pre/post tool hooks → structured S_TOOL_EVENT JSON (never fake text)
|
||||||
* ``send_clarify`` → structured S_CLARIFY_REQUEST
|
* ``send_clarify`` → structured S_CLARIFY_REQUEST
|
||||||
* ``send_document`` etc. → attachment registration + S_ATTACHMENT_ADDED
|
* ``send_document`` etc. → attachment registration + S_ATTACHMENT_ADDED
|
||||||
|
|
||||||
@@ -18,25 +18,15 @@ to any other platform's messages.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import importlib.util
|
||||||
import logging
|
import logging
|
||||||
import mimetypes
|
from typing import Any, Dict, Optional
|
||||||
import os
|
|
||||||
import time
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
|
||||||
|
|
||||||
try:
|
AIOHTTP_AVAILABLE = importlib.util.find_spec("aiohttp") is not None
|
||||||
import aiohttp
|
|
||||||
from aiohttp import web
|
|
||||||
AIOHTTP_AVAILABLE = True
|
|
||||||
except ImportError: # pragma: no cover
|
|
||||||
AIOHTTP_AVAILABLE = False
|
|
||||||
|
|
||||||
from gateway.config import Platform, PlatformConfig
|
from gateway.config import Platform, PlatformConfig
|
||||||
from gateway.platforms.base import (
|
from gateway.platforms.base import (
|
||||||
BasePlatformAdapter,
|
BasePlatformAdapter,
|
||||||
MessageEvent,
|
|
||||||
MessageType,
|
|
||||||
SendResult,
|
SendResult,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -46,9 +36,6 @@ from .config import PhebyConfig, load_config
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_MEDIA_TAG_RE = None # populated lazily from base module helpers
|
|
||||||
|
|
||||||
|
|
||||||
class PhebyAdapter(BasePlatformAdapter):
|
class PhebyAdapter(BasePlatformAdapter):
|
||||||
"""Serve the Pheby WebSocket/HTTP protocol and map it onto the gateway."""
|
"""Serve the Pheby WebSocket/HTTP protocol and map it onto the gateway."""
|
||||||
|
|
||||||
@@ -74,8 +61,13 @@ class PhebyAdapter(BasePlatformAdapter):
|
|||||||
super().__init__(config=config, platform=platform)
|
super().__init__(config=config, platform=platform)
|
||||||
self._pcfg: PhebyConfig = load_config(config.extra or {})
|
self._pcfg: PhebyConfig = load_config(config.extra or {})
|
||||||
self._server: Any = None
|
self._server: Any = None
|
||||||
|
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
||||||
self._drafts: Dict[str, Dict[str, Any]] = {} # conv → draft state
|
self._drafts: Dict[str, Dict[str, Any]] = {} # conv → draft state
|
||||||
self._typing: Dict[str, float] = {}
|
|
||||||
|
@property
|
||||||
|
def authorization_is_upstream(self) -> bool:
|
||||||
|
"""The authenticated WebSocket transport already authorized input."""
|
||||||
|
return True
|
||||||
|
|
||||||
# ── connection lifecycle ─────────────────────────────────────────────
|
# ── connection lifecycle ─────────────────────────────────────────────
|
||||||
async def connect(self, *, is_reconnect: bool = False) -> bool:
|
async def connect(self, *, is_reconnect: bool = False) -> bool:
|
||||||
@@ -90,11 +82,15 @@ class PhebyAdapter(BasePlatformAdapter):
|
|||||||
retryable=False)
|
retryable=False)
|
||||||
return False
|
return False
|
||||||
from .server import PhebyServer
|
from .server import PhebyServer
|
||||||
|
self._loop = asyncio.get_running_loop()
|
||||||
hermes_bridge.set_adapter(self)
|
hermes_bridge.set_adapter(self)
|
||||||
self._server = PhebyServer(self._pcfg, adapter=self)
|
self._server = PhebyServer(self._pcfg, adapter=self)
|
||||||
hermes_bridge.set_server(self._server)
|
hermes_bridge.set_server(self._server)
|
||||||
ok = await self._server.start()
|
ok = await self._server.start()
|
||||||
if not ok:
|
if not ok:
|
||||||
|
self._server = None
|
||||||
|
hermes_bridge.clear_services(self)
|
||||||
|
self._loop = None
|
||||||
return False
|
return False
|
||||||
self._mark_connected()
|
self._mark_connected()
|
||||||
logger.info("[pheby] adapter connected (protocol v%d)",
|
logger.info("[pheby] adapter connected (protocol v%d)",
|
||||||
@@ -106,6 +102,8 @@ class PhebyAdapter(BasePlatformAdapter):
|
|||||||
if self._server is not None:
|
if self._server is not None:
|
||||||
await self._server.stop()
|
await self._server.stop()
|
||||||
self._server = None
|
self._server = None
|
||||||
|
hermes_bridge.clear_services(self)
|
||||||
|
self._loop = None
|
||||||
self._mark_disconnected()
|
self._mark_disconnected()
|
||||||
logger.info("[pheby] adapter disconnected")
|
logger.info("[pheby] adapter disconnected")
|
||||||
|
|
||||||
@@ -123,29 +121,61 @@ class PhebyAdapter(BasePlatformAdapter):
|
|||||||
) -> SendResult:
|
) -> SendResult:
|
||||||
"""Deliver assistant text (final response, commentary, or notices).
|
"""Deliver assistant text (final response, commentary, or notices).
|
||||||
|
|
||||||
The stream consumer calls ``send`` for the first streamed chunk and
|
A streaming preview (``expect_edits``) remains a draft; only the final
|
||||||
the gateway calls it for the final response; both land as
|
send or a finalizing edit emits ``S_MESSAGE_COMPLETE`` and closes the
|
||||||
``S_MESSAGE_COMPLETE``. Streamed deltas ride ``edit_message``.
|
run. Streamed cumulative updates ride ``edit_message``.
|
||||||
"""
|
"""
|
||||||
if self._server is None:
|
if self._server is None:
|
||||||
return SendResult(success=False, error="server not running")
|
return SendResult(success=False, error="server not running")
|
||||||
conversation_id = self._conv_from_chat_id(chat_id)
|
conversation_id = self._conv_from_chat_id(chat_id)
|
||||||
message_id = f"m-{proto.new_id()[:12]}"
|
metadata = metadata or {}
|
||||||
|
|
||||||
# A draft exists while the turn streams; the final text supersedes
|
# Commentary and gateway notices are complete standalone timeline
|
||||||
# the draft and closes it out.
|
# items. They must not close the primary assistant draft/run.
|
||||||
draft = self._drafts.pop(conversation_id, None)
|
if metadata.get("_interim_send") or metadata.get("non_conversational"):
|
||||||
|
message_id = f"m-{proto.new_id()[:12]}"
|
||||||
|
event = {
|
||||||
|
"type": proto.S_MESSAGE_COMPLETE,
|
||||||
|
"conversation_id": conversation_id,
|
||||||
|
"message_id": message_id,
|
||||||
|
"text": content,
|
||||||
|
"kind": ("notice" if metadata.get("non_conversational")
|
||||||
|
else "commentary"),
|
||||||
|
"ts": proto.now_iso(),
|
||||||
|
}
|
||||||
|
await self._server.broadcast(event)
|
||||||
|
return SendResult(success=True, message_id=message_id)
|
||||||
|
|
||||||
|
draft = self._drafts.setdefault(conversation_id, {
|
||||||
|
"message_id": f"draft-{proto.new_id()[:12]}", "text": ""})
|
||||||
|
draft["text"] = content
|
||||||
|
|
||||||
|
# Hermes uses send(expect_edits=True) for the first visible streaming
|
||||||
|
# preview. It is a cumulative draft update, not a completed message.
|
||||||
|
if metadata.get("expect_edits"):
|
||||||
|
await self._server.broadcast({
|
||||||
|
"type": proto.S_MESSAGE_DELTA,
|
||||||
|
"conversation_id": conversation_id,
|
||||||
|
"run_id": hermes_bridge.active_run_id(conversation_id),
|
||||||
|
"message_id": draft["message_id"],
|
||||||
|
"text": content,
|
||||||
|
"ts": proto.now_iso(),
|
||||||
|
})
|
||||||
|
return SendResult(success=True, message_id=draft["message_id"])
|
||||||
|
|
||||||
|
# Fresh/fallback final sends carry notify=True. A non-streaming final
|
||||||
|
# may arrive without metadata, so any ordinary send that reaches this
|
||||||
|
# path is also treated as a completed assistant message.
|
||||||
|
draft = self._drafts.pop(conversation_id, draft)
|
||||||
|
message_id = draft["message_id"]
|
||||||
event = {
|
event = {
|
||||||
"type": proto.S_MESSAGE_COMPLETE,
|
"type": proto.S_MESSAGE_COMPLETE,
|
||||||
"conversation_id": conversation_id,
|
"conversation_id": conversation_id,
|
||||||
"message_id": (draft or {}).get("message_id", message_id),
|
"run_id": hermes_bridge.active_run_id(conversation_id),
|
||||||
|
"message_id": message_id,
|
||||||
"text": content,
|
"text": content,
|
||||||
"ts": proto.now_iso(),
|
"ts": proto.now_iso(),
|
||||||
}
|
}
|
||||||
if metadata and metadata.get("non_conversational"):
|
|
||||||
# Gateway lifecycle/status notices — deliver as a system note so
|
|
||||||
# the client can render them differently (or ignore).
|
|
||||||
event["kind"] = "notice"
|
|
||||||
await self._server.broadcast(event)
|
await self._server.broadcast(event)
|
||||||
hermes_bridge.note_run_finished(conversation_id, "completed")
|
hermes_bridge.note_run_finished(conversation_id, "completed")
|
||||||
return SendResult(success=True, message_id=message_id)
|
return SendResult(success=True, message_id=message_id)
|
||||||
@@ -162,8 +192,8 @@ class PhebyAdapter(BasePlatformAdapter):
|
|||||||
"""Streaming path: GatewayStreamConsumer edits the in-place draft.
|
"""Streaming path: GatewayStreamConsumer edits the in-place draft.
|
||||||
|
|
||||||
The stream-consumer contract requires concrete adapters to accept
|
The stream-consumer contract requires concrete adapters to accept
|
||||||
``finalize=`` even when ignored (it's False during progressive
|
``finalize=``. It is false during progressive edits and true when the
|
||||||
edits; the final content always arrives via ``send()``).
|
stream consumer itself owns final delivery.
|
||||||
"""
|
"""
|
||||||
if self._server is None:
|
if self._server is None:
|
||||||
return SendResult(success=False, error="server not running")
|
return SendResult(success=False, error="server not running")
|
||||||
@@ -173,13 +203,19 @@ class PhebyAdapter(BasePlatformAdapter):
|
|||||||
"text": "",
|
"text": "",
|
||||||
})
|
})
|
||||||
draft["text"] = content # consumer sends cumulative text
|
draft["text"] = content # consumer sends cumulative text
|
||||||
await self._server.broadcast({
|
event = {
|
||||||
"type": proto.S_MESSAGE_DELTA,
|
"type": (proto.S_MESSAGE_COMPLETE if finalize
|
||||||
|
else proto.S_MESSAGE_DELTA),
|
||||||
"conversation_id": conversation_id,
|
"conversation_id": conversation_id,
|
||||||
|
"run_id": hermes_bridge.active_run_id(conversation_id),
|
||||||
"message_id": draft["message_id"],
|
"message_id": draft["message_id"],
|
||||||
"text": content,
|
"text": content,
|
||||||
"ts": proto.now_iso(),
|
"ts": proto.now_iso(),
|
||||||
})
|
}
|
||||||
|
await self._server.broadcast(event)
|
||||||
|
if finalize:
|
||||||
|
self._drafts.pop(conversation_id, None)
|
||||||
|
hermes_bridge.note_run_finished(conversation_id, "completed")
|
||||||
return SendResult(success=True, message_id=draft["message_id"])
|
return SendResult(success=True, message_id=draft["message_id"])
|
||||||
|
|
||||||
# ── structured stream events ─────────────────────────────────────────
|
# ── structured stream events ─────────────────────────────────────────
|
||||||
@@ -191,80 +227,75 @@ class PhebyAdapter(BasePlatformAdapter):
|
|||||||
S_TOOL_EVENT broadcast and return None so the gateway's text queue
|
S_TOOL_EVENT broadcast and return None so the gateway's text queue
|
||||||
stays clean. (The dispatcher treats None as "adapter ate the event".)
|
stays clean. (The dispatcher treats None as "adapter ate the event".)
|
||||||
"""
|
"""
|
||||||
try:
|
# Authoritative structured events come from the pre/post_tool_call
|
||||||
conversation_id = self._active_conversation_id()
|
# hooks, which include session_id + tool_call_id. Eating this display
|
||||||
if not conversation_id or self._server is None:
|
# event avoids duplicate/ambiguously-routed tool chrome.
|
||||||
return None
|
return None
|
||||||
if isinstance(event, ToolCallShim):
|
|
||||||
return None # never used at runtime; type-safety shim only
|
|
||||||
from gateway.stream_events import ToolCallChunk, ToolCallFinished
|
|
||||||
tool_event: Dict[str, Any]
|
|
||||||
if isinstance(event, ToolCallChunk):
|
|
||||||
tool_id = f"t-{proto.new_id()[:12]}"
|
|
||||||
args = event.args if isinstance(event.args, dict) else None
|
|
||||||
self._remember_tool(tool_id, conversation_id, event.tool_name)
|
|
||||||
tool_event = {
|
|
||||||
"type": proto.S_TOOL_EVENT,
|
|
||||||
"conversation_id": conversation_id,
|
|
||||||
"tool_call_id": tool_id,
|
|
||||||
"tool_name": event.tool_name,
|
|
||||||
"status": "running",
|
|
||||||
"description": proto.safe_str(event.preview, 300)
|
|
||||||
if event.preview else None,
|
|
||||||
"args_redacted": _redact_args(args),
|
|
||||||
"ts": proto.now_iso(),
|
|
||||||
}
|
|
||||||
elif isinstance(event, ToolCallFinished):
|
|
||||||
tool_id = self._lookup_tool(event.tool_name, conversation_id)
|
|
||||||
tool_event = {
|
|
||||||
"type": proto.S_TOOL_EVENT,
|
|
||||||
"conversation_id": conversation_id,
|
|
||||||
"tool_call_id": tool_id,
|
|
||||||
"tool_name": event.tool_name,
|
|
||||||
"status": "completed" if event.ok else "failed",
|
|
||||||
"duration_ms": int(event.duration * 1000)
|
|
||||||
if event.duration else None,
|
|
||||||
"ts": proto.now_iso(),
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
asyncio.ensure_future(self._server.broadcast(tool_event))
|
|
||||||
except Exception:
|
|
||||||
logger.debug("[pheby] tool event translation failed",
|
|
||||||
exc_info=True)
|
|
||||||
return None # never render tool chrome as chat text
|
|
||||||
|
|
||||||
def _tool_state(self) -> Dict[str, Any]:
|
|
||||||
if not hasattr(self, "_tool_calls"):
|
|
||||||
self._tool_calls: Dict[Tuple[str, str], str] = {}
|
|
||||||
self._tool_order: List[Tuple[str, str]] = []
|
|
||||||
return {"calls": self._tool_calls, "order": self._tool_order}
|
|
||||||
|
|
||||||
def _remember_tool(self, tool_id: str, conversation_id: str,
|
|
||||||
tool_name: str) -> None:
|
|
||||||
state = self._tool_state()
|
|
||||||
state["calls"][(tool_name, conversation_id)] = tool_id
|
|
||||||
state["order"].append((tool_name, conversation_id))
|
|
||||||
if len(state["order"]) > 200:
|
|
||||||
old = state["order"].pop(0)
|
|
||||||
state["calls"].pop(old, None)
|
|
||||||
|
|
||||||
def _lookup_tool(self, tool_name: str, conversation_id: str) -> str:
|
|
||||||
state = self._tool_state()
|
|
||||||
return state["calls"].get((tool_name, conversation_id),
|
|
||||||
f"t-{proto.new_id()[:12]}")
|
|
||||||
|
|
||||||
# -- Hermes plugin hooks (registered in __init__.py register()) --------
|
# -- Hermes plugin hooks (registered in __init__.py register()) --------
|
||||||
|
def schedule_broadcast(self, payload: Dict[str, Any]) -> None:
|
||||||
|
"""Thread-safe hook → aiohttp-loop delivery."""
|
||||||
|
loop, server = self._loop, self._server
|
||||||
|
if loop is None or server is None or loop.is_closed():
|
||||||
|
return
|
||||||
|
|
||||||
|
def _spawn() -> None:
|
||||||
|
asyncio.create_task(server.broadcast(dict(payload)))
|
||||||
|
try:
|
||||||
|
if asyncio.get_running_loop() is loop:
|
||||||
|
_spawn()
|
||||||
|
return
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
|
loop.call_soon_threadsafe(_spawn)
|
||||||
|
|
||||||
|
def _conversation_for_session_id(self, session_id: str) -> Optional[str]:
|
||||||
|
runner = getattr(self, "gateway_runner", None)
|
||||||
|
store = getattr(runner, "session_store", None) if runner else None
|
||||||
|
if store is None or not session_id:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
for entry in store.list_sessions():
|
||||||
|
if str(getattr(entry, "session_id", "")) != str(session_id):
|
||||||
|
continue
|
||||||
|
origin = getattr(entry, "origin", None)
|
||||||
|
if getattr(getattr(origin, "platform", None), "value", "") == "pheby":
|
||||||
|
return str(getattr(origin, "chat_id", "") or "") or None
|
||||||
|
except Exception:
|
||||||
|
logger.debug("[pheby] tool session lookup failed", exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def on_pre_tool_call(self, **kwargs: Any) -> None:
|
||||||
|
conversation_id = self._conversation_for_session_id(
|
||||||
|
str(kwargs.get("session_id") or ""))
|
||||||
|
if not conversation_id:
|
||||||
|
return
|
||||||
|
tool_id = str(kwargs.get("tool_call_id") or
|
||||||
|
f"t-{proto.new_id()[:12]}")
|
||||||
|
event = {
|
||||||
|
"type": proto.S_TOOL_EVENT,
|
||||||
|
"conversation_id": conversation_id,
|
||||||
|
"run_id": hermes_bridge.active_run_id(conversation_id),
|
||||||
|
"tool_call_id": tool_id,
|
||||||
|
"tool_name": str(kwargs.get("tool_name") or "tool"),
|
||||||
|
"status": "running",
|
||||||
|
"args_redacted": _redact_args(kwargs.get("args")),
|
||||||
|
"ts": proto.now_iso(),
|
||||||
|
}
|
||||||
|
hermes_bridge.record_tool_event(conversation_id, event)
|
||||||
|
self.schedule_broadcast(event)
|
||||||
|
|
||||||
def on_post_tool_call(self, **kwargs: Any) -> None:
|
def on_post_tool_call(self, **kwargs: Any) -> None:
|
||||||
"""Observer for the ``post_tool_call`` plugin hook.
|
"""Observer for the ``post_tool_call`` plugin hook.
|
||||||
|
|
||||||
Hermes fires this after every tool execution with the authoritative
|
Hermes fires this after every tool execution with the authoritative
|
||||||
tool_call_id, status, duration, and result. We relay it as a
|
tool_call_id, status, duration, and result. We relay it as a
|
||||||
structured ``S_TOOL_EVENT`` so the client can settle the matching
|
structured ``S_TOOL_EVENT`` so the client can settle the matching
|
||||||
"running" event emitted by ``format_tool_event``.
|
running event emitted by ``on_pre_tool_call``.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
conversation_id = self._active_conversation_id()
|
conversation_id = self._conversation_for_session_id(
|
||||||
|
str(kwargs.get("session_id") or ""))
|
||||||
if not conversation_id or self._server is None:
|
if not conversation_id or self._server is None:
|
||||||
return
|
return
|
||||||
tool_name = str(kwargs.get("tool_name") or "tool")
|
tool_name = str(kwargs.get("tool_name") or "tool")
|
||||||
@@ -273,12 +304,14 @@ class PhebyAdapter(BasePlatformAdapter):
|
|||||||
event = {
|
event = {
|
||||||
"type": proto.S_TOOL_EVENT,
|
"type": proto.S_TOOL_EVENT,
|
||||||
"conversation_id": conversation_id,
|
"conversation_id": conversation_id,
|
||||||
|
"run_id": hermes_bridge.active_run_id(conversation_id),
|
||||||
"tool_call_id": str(kwargs.get("tool_call_id")
|
"tool_call_id": str(kwargs.get("tool_call_id")
|
||||||
or self._lookup_tool(tool_name,
|
or f"t-{proto.new_id()[:12]}"),
|
||||||
conversation_id)),
|
|
||||||
"tool_name": tool_name,
|
"tool_name": tool_name,
|
||||||
"status": "completed" if status in ("ok", "success", "")
|
"status": "completed" if status in ("ok", "success", "")
|
||||||
else "failed" if status == "error" else status or "completed",
|
else "cancelled" if status == "cancelled"
|
||||||
|
else "failed" if status in ("error", "blocked")
|
||||||
|
else status or "completed",
|
||||||
"duration_ms": int(duration_ms) if duration_ms else None,
|
"duration_ms": int(duration_ms) if duration_ms else None,
|
||||||
# Result summaries are intentionally NOT included by default:
|
# Result summaries are intentionally NOT included by default:
|
||||||
# tool results can embed file paths/host details. The client
|
# tool results can embed file paths/host details. The client
|
||||||
@@ -288,47 +321,43 @@ class PhebyAdapter(BasePlatformAdapter):
|
|||||||
error_message = kwargs.get("error_message")
|
error_message = kwargs.get("error_message")
|
||||||
if error_message and event["status"] == "failed":
|
if error_message and event["status"] == "failed":
|
||||||
event["error"] = proto.safe_str(error_message, 200)
|
event["error"] = proto.safe_str(error_message, 200)
|
||||||
asyncio.ensure_future(self._server.broadcast(event))
|
hermes_bridge.record_tool_event(conversation_id, event)
|
||||||
|
self.schedule_broadcast(event)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("[pheby] post_tool_call relay failed", exc_info=True)
|
logger.debug("[pheby] post_tool_call relay failed", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
def _active_conversation_id(self) -> Optional[str]:
|
|
||||||
"""Best-effort current conversation for adapter-level callbacks."""
|
|
||||||
if not self._active_sessions:
|
|
||||||
return None
|
|
||||||
# Most recent active session wins (single-user platform).
|
|
||||||
key = sorted(self._active_sessions.keys())[-1]
|
|
||||||
# Session keys end with :dm:<conversation_id>
|
|
||||||
return key.rsplit(":", 1)[-1] if ":" in key else None
|
|
||||||
|
|
||||||
# ── typing indicator → run activity ──────────────────────────────────
|
# ── typing indicator → run activity ──────────────────────────────────
|
||||||
async def send_typing(self, chat_id: str, metadata=None) -> None:
|
async def send_typing(self, chat_id: str, metadata=None) -> None:
|
||||||
# Pheby clients show their own activity UI from run/tool events.
|
# Pheby clients show their own activity UI from run/tool events.
|
||||||
return
|
return
|
||||||
|
|
||||||
# ── approvals ────────────────────────────────────────────────────────
|
# ── approvals ────────────────────────────────────────────────────────
|
||||||
async def send_approval_prompt(self, session_key: str,
|
async def send_exec_approval(
|
||||||
approval_data: Dict[str, Any]) -> None:
|
self,
|
||||||
"""Called from the approval notify callback (agent thread → here)."""
|
chat_id: str,
|
||||||
|
command: str,
|
||||||
|
session_key: str,
|
||||||
|
description: str = "dangerous command",
|
||||||
|
metadata: Optional[Dict[str, Any]] = None,
|
||||||
|
allow_permanent: bool = True,
|
||||||
|
allow_session: bool = True,
|
||||||
|
smart_denied: bool = False,
|
||||||
|
) -> SendResult:
|
||||||
|
"""Hermes's native structured-approval extension point."""
|
||||||
|
if self._server is None:
|
||||||
|
return SendResult(success=False, error="server not running")
|
||||||
try:
|
try:
|
||||||
await hermes_bridge.push_approval(approval_data, session_key)
|
await hermes_bridge.push_approval({
|
||||||
except Exception:
|
"command": command,
|
||||||
|
"description": description,
|
||||||
|
"allow_permanent": allow_permanent and not smart_denied,
|
||||||
|
"allow_session": allow_session and not smart_denied,
|
||||||
|
}, session_key)
|
||||||
|
return SendResult(success=True,
|
||||||
|
message_id=f"approval-{proto.new_id()[:12]}")
|
||||||
|
except Exception as exc:
|
||||||
logger.error("[pheby] approval push failed", exc_info=True)
|
logger.error("[pheby] approval push failed", exc_info=True)
|
||||||
|
return SendResult(success=False, error=str(exc))
|
||||||
def register_approval_notify(self, session_key: str) -> None:
|
|
||||||
"""Wire tools.approval's per-session notify callback to Pheby."""
|
|
||||||
from tools.approval import register_gateway_notify, \
|
|
||||||
unregister_gateway_notify
|
|
||||||
loop = asyncio.get_event_loop()
|
|
||||||
# The callback runs on the agent's worker thread; bridge to the loop.
|
|
||||||
def _notify(approval_data: Dict[str, Any]) -> None:
|
|
||||||
asyncio.run_coroutine_threadsafe(
|
|
||||||
self.send_approval_prompt(session_key, approval_data), loop)
|
|
||||||
register_gateway_notify(session_key, _notify)
|
|
||||||
self._approval_notify_sessions = getattr(
|
|
||||||
self, "_approval_notify_sessions", set())
|
|
||||||
self._approval_notify_sessions.add(session_key)
|
|
||||||
|
|
||||||
# ── clarification ────────────────────────────────────────────────────
|
# ── clarification ────────────────────────────────────────────────────
|
||||||
async def send_clarify(
|
async def send_clarify(
|
||||||
@@ -461,25 +490,32 @@ class PhebyAdapter(BasePlatformAdapter):
|
|||||||
return _send
|
return _send
|
||||||
|
|
||||||
|
|
||||||
class ToolCallShim:
|
|
||||||
"""Marker type for internal typing only — never instantiated."""
|
|
||||||
|
|
||||||
|
|
||||||
def _redact_args(args: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
def _redact_args(args: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
||||||
"""Strip likely-secret values from tool args before sending to client."""
|
"""Recursively strip likely-secret values before sending args to client."""
|
||||||
if not isinstance(args, dict):
|
if not isinstance(args, dict):
|
||||||
return None
|
return None
|
||||||
sensitive = ("key", "token", "secret", "password", "credential", "auth")
|
sensitive = ("key", "token", "secret", "password", "credential", "auth")
|
||||||
out: Dict[str, Any] = {}
|
|
||||||
for k, v in args.items():
|
def _clean(value: Any, depth: int = 0) -> Any:
|
||||||
k_l = str(k).lower()
|
if depth > 6:
|
||||||
if any(s in k_l for s in sensitive):
|
return "[truncated]"
|
||||||
out[str(k)] = "[redacted]"
|
if isinstance(value, dict):
|
||||||
elif isinstance(v, str) and len(v) > 500:
|
cleaned: Dict[str, Any] = {}
|
||||||
out[str(k)] = v[:500] + "…"
|
for key, nested in list(value.items())[:100]:
|
||||||
else:
|
key_s = str(key)
|
||||||
out[str(k)] = v
|
cleaned[key_s] = ("[redacted]" if any(
|
||||||
return out
|
marker in key_s.lower() for marker in sensitive)
|
||||||
|
else _clean(nested, depth + 1))
|
||||||
|
return cleaned
|
||||||
|
if isinstance(value, (list, tuple)):
|
||||||
|
return [_clean(item, depth + 1) for item in list(value)[:100]]
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value[:500] + ("…" if len(value) > 500 else "")
|
||||||
|
if value is None or isinstance(value, (bool, int, float)):
|
||||||
|
return value
|
||||||
|
return proto.safe_str(value, 500)
|
||||||
|
|
||||||
|
return _clean(args)
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["PhebyAdapter", "AIOHTTP_AVAILABLE"]
|
__all__ = ["PhebyAdapter", "AIOHTTP_AVAILABLE"]
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ class AttachmentStore:
|
|||||||
def describe(self, attachment_id: str) -> Optional[Dict[str, Any]]:
|
def describe(self, attachment_id: str) -> Optional[Dict[str, Any]]:
|
||||||
"""Public descriptor for an attachment (no server paths)."""
|
"""Public descriptor for an attachment (no server paths)."""
|
||||||
meta = self._meta.get(attachment_id)
|
meta = self._meta.get(attachment_id)
|
||||||
if not meta:
|
if not meta or self._is_expired(meta):
|
||||||
return None
|
return None
|
||||||
return {
|
return {
|
||||||
"attachment_id": attachment_id,
|
"attachment_id": attachment_id,
|
||||||
|
|||||||
+313
-100
@@ -9,9 +9,9 @@ so the module can be imported by unit tests without a Hermes install.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import contextvars
|
|
||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
@@ -27,6 +27,15 @@ _PENDING_CLARIFIES: Dict[str, Dict[str, Any]] = {}
|
|||||||
|
|
||||||
_RUN_LOCK = threading.Lock()
|
_RUN_LOCK = threading.Lock()
|
||||||
_ACTIVE_RUNS: Dict[str, Dict[str, Any]] = {} # conversation_id → run info
|
_ACTIVE_RUNS: Dict[str, Dict[str, Any]] = {} # conversation_id → run info
|
||||||
|
_TOOL_EVENTS: Dict[str, Dict[str, Dict[str, Any]]] = {}
|
||||||
|
|
||||||
|
# The adapter and server are process services, not request-local values.
|
||||||
|
# ContextVars lose their values when Hermes calls plugin hooks from agent
|
||||||
|
# worker threads, which made approvals/tool events disappear. Access is
|
||||||
|
# guarded because hook callbacks can arrive from multiple workers.
|
||||||
|
_SERVICE_LOCK = threading.RLock()
|
||||||
|
_ADAPTER: Any = None
|
||||||
|
_SERVER: Any = None
|
||||||
|
|
||||||
|
|
||||||
def _runner() -> Any:
|
def _runner() -> Any:
|
||||||
@@ -35,16 +44,24 @@ def _runner() -> Any:
|
|||||||
return getattr(adapter, "gateway_runner", None) if adapter else None
|
return getattr(adapter, "gateway_runner", None) if adapter else None
|
||||||
|
|
||||||
|
|
||||||
_ADAPTER_CTX: contextvars.ContextVar = contextvars.ContextVar(
|
|
||||||
"pheby_adapter", default=None)
|
|
||||||
|
|
||||||
|
|
||||||
def _current_adapter() -> Any:
|
def _current_adapter() -> Any:
|
||||||
return _ADAPTER_CTX.get()
|
with _SERVICE_LOCK:
|
||||||
|
return _ADAPTER
|
||||||
|
|
||||||
|
|
||||||
def set_adapter(adapter: Any) -> None:
|
def set_adapter(adapter: Any) -> None:
|
||||||
_ADAPTER_CTX.set(adapter)
|
global _ADAPTER
|
||||||
|
with _SERVICE_LOCK:
|
||||||
|
_ADAPTER = adapter
|
||||||
|
|
||||||
|
|
||||||
|
def clear_services(adapter: Any = None) -> None:
|
||||||
|
"""Release process-wide references when the owning adapter disconnects."""
|
||||||
|
global _ADAPTER, _SERVER
|
||||||
|
with _SERVICE_LOCK:
|
||||||
|
if adapter is None or _ADAPTER is adapter:
|
||||||
|
_ADAPTER = None
|
||||||
|
_SERVER = None
|
||||||
|
|
||||||
|
|
||||||
def _session_store() -> Any:
|
def _session_store() -> Any:
|
||||||
@@ -99,15 +116,45 @@ def _session_key_for(conversation_id: str) -> str:
|
|||||||
return ConversationRouter.session_key_for(conversation_id)
|
return ConversationRouter.session_key_for(conversation_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _conversation_from_session_key(session_key: str) -> str:
|
||||||
|
"""Extract the opaque chat ID from a Pheby DM session key."""
|
||||||
|
marker = ":pheby:dm:"
|
||||||
|
if marker in str(session_key):
|
||||||
|
return str(session_key).split(marker, 1)[1]
|
||||||
|
return str(session_key).rsplit(":", 1)[-1]
|
||||||
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
# Conversations
|
# Conversations
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
async def create_conversation(server: Any, name: Optional[str]) -> str:
|
||||||
|
"""Create the Pheby ID and an empty Hermes session routing entry."""
|
||||||
|
cid = await server.router.new_conversation(name)
|
||||||
|
store = _session_store()
|
||||||
|
if store is not None:
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(
|
||||||
|
store.get_or_create_session, _source_for(cid), False, False)
|
||||||
|
if name:
|
||||||
|
await rename_conversation(cid, name)
|
||||||
|
except Exception:
|
||||||
|
# Keep the router entry so the empty conversation remains usable;
|
||||||
|
# the first message can create its Hermes session normally.
|
||||||
|
logger.warning("[pheby] empty Hermes session creation failed",
|
||||||
|
exc_info=True)
|
||||||
|
return cid
|
||||||
|
|
||||||
|
|
||||||
async def list_conversations(server: Any = None) -> List[Dict[str, Any]]:
|
async def list_conversations(server: Any = None) -> List[Dict[str, Any]]:
|
||||||
"""Enumerate conversations known to the router + Hermes session store."""
|
"""Enumerate conversations known to the router + Hermes session store."""
|
||||||
server = server or _current_server()
|
server = server or _current_server()
|
||||||
router = server.router if server else None
|
router = server.router if server else None
|
||||||
out: List[Dict[str, Any]] = []
|
out: List[Dict[str, Any]] = []
|
||||||
seen: set = set()
|
seen: set = set()
|
||||||
|
router_names: Dict[str, str] = {}
|
||||||
|
if router is not None:
|
||||||
|
for cid in await router.known_ids():
|
||||||
|
router_names[cid] = await router.get_name(cid) or cid
|
||||||
|
|
||||||
# 1. Sessions Hermes already tracks for the pheby platform.
|
# 1. Sessions Hermes already tracks for the pheby platform.
|
||||||
store = _session_store()
|
store = _session_store()
|
||||||
@@ -126,8 +173,10 @@ async def list_conversations(server: Any = None) -> List[Dict[str, Any]]:
|
|||||||
seen.add(cid)
|
seen.add(cid)
|
||||||
out.append({
|
out.append({
|
||||||
"conversation_id": cid,
|
"conversation_id": cid,
|
||||||
"name": (getattr(entry, "display_name", None)
|
# An explicit Pheby rename wins over Hermes's initial
|
||||||
or _router_name(router, cid) or cid),
|
# source-derived display name.
|
||||||
|
"name": (router_names.get(cid)
|
||||||
|
or getattr(entry, "display_name", None) or cid),
|
||||||
"session_id": getattr(entry, "session_id", None),
|
"session_id": getattr(entry, "session_id", None),
|
||||||
"last_active": _iso(getattr(entry, "updated_at", None)),
|
"last_active": _iso(getattr(entry, "updated_at", None)),
|
||||||
"source": "hermes",
|
"source": "hermes",
|
||||||
@@ -155,12 +204,6 @@ async def list_conversations(server: Any = None) -> List[Dict[str, Any]]:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _router_name(router: Any, cid: str) -> Optional[str]:
|
|
||||||
if router is None:
|
|
||||||
return None
|
|
||||||
return router._names.get(cid)
|
|
||||||
|
|
||||||
|
|
||||||
def _iso(value: Any) -> Optional[str]:
|
def _iso(value: Any) -> Optional[str]:
|
||||||
try:
|
try:
|
||||||
return value.isoformat() if value else None
|
return value.isoformat() if value else None
|
||||||
@@ -182,7 +225,6 @@ async def conversation_history(conversation_id: str, limit: int
|
|||||||
session_id: Optional[str] = None
|
session_id: Optional[str] = None
|
||||||
if store is not None:
|
if store is not None:
|
||||||
try:
|
try:
|
||||||
source = _source_for(conversation_id)
|
|
||||||
entry = await asyncio.to_thread(store.peek_session_id,
|
entry = await asyncio.to_thread(store.peek_session_id,
|
||||||
_session_key_for(conversation_id))
|
_session_key_for(conversation_id))
|
||||||
if entry:
|
if entry:
|
||||||
@@ -195,7 +237,8 @@ async def conversation_history(conversation_id: str, limit: int
|
|||||||
if db is not None and session_id:
|
if db is not None and session_id:
|
||||||
try:
|
try:
|
||||||
rows = await asyncio.to_thread(
|
rows = await asyncio.to_thread(
|
||||||
db.get_messages_as_conversation, session_id)
|
db.get_messages_as_conversation, session_id,
|
||||||
|
include_row_ids=True)
|
||||||
for row in rows[-limit:]:
|
for row in rows[-limit:]:
|
||||||
role = row.get("role")
|
role = row.get("role")
|
||||||
if role not in ("user", "assistant"):
|
if role not in ("user", "assistant"):
|
||||||
@@ -207,8 +250,8 @@ async def conversation_history(conversation_id: str, limit: int
|
|||||||
if not text.strip() and role == "assistant":
|
if not text.strip() and role == "assistant":
|
||||||
continue
|
continue
|
||||||
messages.append({
|
messages.append({
|
||||||
"message_id": f"m{row.get('id', len(messages))}"
|
"message_id": f"m{row.get('_row_id')}"
|
||||||
if isinstance(row.get("id"), (int, str)) else None,
|
if isinstance(row.get("_row_id"), (int, str)) else None,
|
||||||
"role": role,
|
"role": role,
|
||||||
"text": text,
|
"text": text,
|
||||||
"ts": row.get("timestamp") if isinstance(
|
"ts": row.get("timestamp") if isinstance(
|
||||||
@@ -229,44 +272,88 @@ async def conversation_history(conversation_id: str, limit: int
|
|||||||
return messages, found
|
return messages, found
|
||||||
|
|
||||||
|
|
||||||
async def delete_conversation(conversation_id: str) -> bool:
|
async def rename_conversation(conversation_id: str, name: str) -> bool:
|
||||||
"""Delete a conversation from the router + Hermes (best effort on DB).
|
"""Rename the Pheby index and the live Hermes routing entry."""
|
||||||
|
|
||||||
Hermes limitation: the SessionStore has no public per-key delete; the
|
|
||||||
authoritative delete is ``SessionDB.delete_session`` on the current
|
|
||||||
session id. The routing entry is also reset so the next message starts
|
|
||||||
a fresh session. Documented approximation — see README limitations.
|
|
||||||
"""
|
|
||||||
server = _current_server()
|
server = _current_server()
|
||||||
router = server.router if server else None
|
router = server.router if server else None
|
||||||
if router is None:
|
if router is None:
|
||||||
return False
|
return False
|
||||||
if not await router.forget(conversation_id):
|
if not await router.rename(conversation_id, name):
|
||||||
|
return False
|
||||||
|
|
||||||
|
store = _session_store()
|
||||||
|
if store is not None:
|
||||||
|
session_key = _session_key_for(conversation_id)
|
||||||
|
|
||||||
|
def _rename_route() -> None:
|
||||||
|
with store._lock:
|
||||||
|
store._ensure_loaded_locked()
|
||||||
|
entry = store._entries.get(session_key)
|
||||||
|
if entry is not None:
|
||||||
|
entry.display_name = name
|
||||||
|
store._save()
|
||||||
|
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(_rename_route)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("[pheby] Hermes display-name update failed",
|
||||||
|
exc_info=True)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_conversation(conversation_id: str) -> bool:
|
||||||
|
"""Delete a transcript and remove its live routing entry.
|
||||||
|
|
||||||
|
Hermes currently has no public per-key removal method. We therefore use
|
||||||
|
the same lock/save discipline as SessionStore's own pruning code. Calling
|
||||||
|
``reset_session`` here would create a replacement entry and make the
|
||||||
|
deleted conversation immediately reappear.
|
||||||
|
"""
|
||||||
|
server = _current_server()
|
||||||
|
router = server.router if server else None
|
||||||
|
if router is None or active_run(conversation_id) is not None:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
store = _session_store()
|
store = _session_store()
|
||||||
db = _session_db()
|
db = _session_db()
|
||||||
|
session_key = _session_key_for(conversation_id)
|
||||||
session_id = None
|
session_id = None
|
||||||
if store is not None:
|
if store is not None:
|
||||||
try:
|
try:
|
||||||
session_id = await asyncio.to_thread(
|
session_id = await asyncio.to_thread(
|
||||||
store.peek_session_id, _session_key_for(conversation_id))
|
store.peek_session_id, session_key)
|
||||||
except Exception:
|
except Exception:
|
||||||
session_id = None
|
session_id = None
|
||||||
|
router_known = await router.get_name(conversation_id) is not None
|
||||||
|
if not router_known and not session_id:
|
||||||
|
return False
|
||||||
|
|
||||||
if session_id and db is not None:
|
if session_id and db is not None:
|
||||||
try:
|
try:
|
||||||
await asyncio.to_thread(db.delete_session, session_id)
|
deleted = await asyncio.to_thread(db.delete_session, session_id)
|
||||||
|
if deleted is False:
|
||||||
|
return False
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("[pheby] session db delete failed", exc_info=True)
|
logger.error("[pheby] session db delete failed", exc_info=True)
|
||||||
if store is not None:
|
return False
|
||||||
try:
|
|
||||||
await asyncio.to_thread(store.reset_session,
|
|
||||||
_session_key_for(conversation_id),
|
|
||||||
None)
|
|
||||||
except Exception:
|
|
||||||
logger.debug("[pheby] store reset failed", exc_info=True)
|
|
||||||
|
|
||||||
_ACTIVE_RUNS.pop(conversation_id, None)
|
if store is not None:
|
||||||
|
def _remove_route() -> None:
|
||||||
|
with store._lock:
|
||||||
|
store._ensure_loaded_locked()
|
||||||
|
if store._entries.pop(session_key, None) is not None:
|
||||||
|
store._save()
|
||||||
|
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(_remove_route)
|
||||||
|
except Exception:
|
||||||
|
logger.error("[pheby] routing removal failed", exc_info=True)
|
||||||
|
return False
|
||||||
|
|
||||||
|
await router.forget(conversation_id)
|
||||||
|
with _RUN_LOCK:
|
||||||
|
_ACTIVE_RUNS.pop(conversation_id, None)
|
||||||
|
_TOOL_EVENTS.pop(conversation_id, None)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
@@ -301,10 +388,19 @@ async def send_chat(server: Any, conversation_id: str, text: str,
|
|||||||
metadata={"pheby_run_id": run_id},
|
metadata={"pheby_run_id": run_id},
|
||||||
)
|
)
|
||||||
|
|
||||||
_ACTIVE_RUNS[conversation_id] = {
|
with _RUN_LOCK:
|
||||||
"run_id": run_id,
|
already_active = conversation_id in _ACTIVE_RUNS
|
||||||
"started": asyncio.get_event_loop().time(),
|
if not already_active:
|
||||||
}
|
_ACTIVE_RUNS[conversation_id] = {
|
||||||
|
"run_id": run_id,
|
||||||
|
"started": asyncio.get_running_loop().time(),
|
||||||
|
}
|
||||||
|
_TOOL_EVENTS[conversation_id] = {}
|
||||||
|
if already_active:
|
||||||
|
await client.send_json(proto.error_event(
|
||||||
|
proto.ERR_RUN_ACTIVE,
|
||||||
|
"A run is already active for this conversation", request_id))
|
||||||
|
return
|
||||||
await client.send_json({
|
await client.send_json({
|
||||||
"type": proto.S_RUN_ACCEPTED,
|
"type": proto.S_RUN_ACCEPTED,
|
||||||
"conversation_id": conversation_id,
|
"conversation_id": conversation_id,
|
||||||
@@ -327,14 +423,73 @@ async def send_chat(server: Any, conversation_id: str, text: str,
|
|||||||
|
|
||||||
# The base adapter's handle_message() spawns background tasks and
|
# The base adapter's handle_message() spawns background tasks and
|
||||||
# returns quickly; the eventual reply arrives through adapter.send().
|
# returns quickly; the eventual reply arrives through adapter.send().
|
||||||
await adapter.handle_message(event)
|
try:
|
||||||
|
await adapter.handle_message(event)
|
||||||
|
except Exception:
|
||||||
|
with _RUN_LOCK:
|
||||||
|
current = _ACTIVE_RUNS.get(conversation_id)
|
||||||
|
if current and current.get("run_id") == run_id:
|
||||||
|
_ACTIVE_RUNS.pop(conversation_id, None)
|
||||||
|
if getattr(adapter, "_drafts", None) is not None:
|
||||||
|
adapter._drafts.pop(conversation_id, None)
|
||||||
|
await server.broadcast({
|
||||||
|
"type": proto.S_RUN_FINISHED,
|
||||||
|
"conversation_id": conversation_id,
|
||||||
|
"run_id": run_id,
|
||||||
|
"status": "failed",
|
||||||
|
"error": "Gateway rejected the message",
|
||||||
|
})
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def active_run(conversation_id: str) -> Optional[Dict[str, Any]]:
|
||||||
|
with _RUN_LOCK:
|
||||||
|
run = _ACTIVE_RUNS.get(conversation_id)
|
||||||
|
return dict(run) if run else None
|
||||||
|
|
||||||
|
|
||||||
|
def active_run_id(conversation_id: str) -> Optional[str]:
|
||||||
|
run = active_run(conversation_id)
|
||||||
|
return str(run["run_id"]) if run else None
|
||||||
|
|
||||||
|
|
||||||
|
def record_tool_event(conversation_id: str, event: Dict[str, Any]) -> None:
|
||||||
|
tool_id = str(event.get("tool_call_id") or "")
|
||||||
|
if not tool_id:
|
||||||
|
return
|
||||||
|
with _RUN_LOCK:
|
||||||
|
bucket = _TOOL_EVENTS.setdefault(conversation_id, {})
|
||||||
|
bucket[tool_id] = dict(event)
|
||||||
|
if len(bucket) > 200:
|
||||||
|
bucket.pop(next(iter(bucket)))
|
||||||
|
|
||||||
|
|
||||||
|
def runtime_snapshot(conversation_id: str) -> Dict[str, Any]:
|
||||||
|
"""Recoverable transient state included by ``conversation.open``."""
|
||||||
|
session_key = _session_key_for(conversation_id)
|
||||||
|
with _RUN_LOCK:
|
||||||
|
run = _ACTIVE_RUNS.get(conversation_id)
|
||||||
|
tools = list(_TOOL_EVENTS.get(conversation_id, {}).values())
|
||||||
|
approvals = [dict(p["event"]) for p in _PENDING_APPROVALS.values()
|
||||||
|
if p.get("session_key") == session_key]
|
||||||
|
clarifications = [dict(p["event"]) for p in _PENDING_CLARIFIES.values()
|
||||||
|
if p.get("session_key") == session_key]
|
||||||
|
return {
|
||||||
|
"run": dict(run) if run else None,
|
||||||
|
"tools": tools,
|
||||||
|
"approvals": approvals,
|
||||||
|
"clarifications": clarifications,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def note_run_finished(conversation_id: str, status: str = "completed",
|
def note_run_finished(conversation_id: str, status: str = "completed",
|
||||||
error: Optional[str] = None) -> None:
|
error: Optional[str] = None) -> None:
|
||||||
"""Called by the adapter when a turn completes/fails."""
|
"""Called by the adapter when a turn completes/fails."""
|
||||||
run = _ACTIVE_RUNS.pop(conversation_id, None)
|
with _RUN_LOCK:
|
||||||
run_id = run["run_id"] if run else None
|
run = _ACTIVE_RUNS.pop(conversation_id, None)
|
||||||
|
if run is None:
|
||||||
|
return
|
||||||
|
run_id = run["run_id"]
|
||||||
server = _current_server()
|
server = _current_server()
|
||||||
if server is None:
|
if server is None:
|
||||||
return
|
return
|
||||||
@@ -348,9 +503,9 @@ def note_run_finished(conversation_id: str, status: str = "completed",
|
|||||||
if error:
|
if error:
|
||||||
payload["error"] = proto.safe_str(error, 300)
|
payload["error"] = proto.safe_str(error, 300)
|
||||||
try:
|
try:
|
||||||
loop = asyncio.get_event_loop()
|
adapter = _current_adapter()
|
||||||
if loop.is_running():
|
if adapter is not None:
|
||||||
asyncio.ensure_future(server.broadcast(payload))
|
adapter.schedule_broadcast(payload)
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -358,8 +513,10 @@ def note_run_finished(conversation_id: str, status: str = "completed",
|
|||||||
async def cancel_run(conversation_id: str, run_id: Optional[str]) -> bool:
|
async def cancel_run(conversation_id: str, run_id: Optional[str]) -> bool:
|
||||||
"""Cancel an active run via Hermes's supported interrupt path."""
|
"""Cancel an active run via Hermes's supported interrupt path."""
|
||||||
adapter = _current_adapter()
|
adapter = _current_adapter()
|
||||||
run = _ACTIVE_RUNS.get(conversation_id)
|
run = active_run(conversation_id)
|
||||||
if run and run_id and run["run_id"] != run_id:
|
if run is None:
|
||||||
|
return False
|
||||||
|
if run_id and run["run_id"] != run_id:
|
||||||
return False # stale run id — nothing to cancel
|
return False # stale run id — nothing to cancel
|
||||||
session_key = _session_key_for(conversation_id)
|
session_key = _session_key_for(conversation_id)
|
||||||
|
|
||||||
@@ -369,10 +526,10 @@ async def cancel_run(conversation_id: str, run_id: Optional[str]) -> bool:
|
|||||||
# Preferred: gateway's own /stop dispatch (cancels task + drains).
|
# Preferred: gateway's own /stop dispatch (cancels task + drains).
|
||||||
running = getattr(runner, "_running_agents", {}).get(session_key)
|
running = getattr(runner, "_running_agents", {}).get(session_key)
|
||||||
agent = running if running is not None else None
|
agent = running if running is not None else None
|
||||||
if agent is not None and agent is not getattr(
|
interrupt = getattr(agent, "interrupt", None)
|
||||||
type(runner), "_AGENT_PENDING_SENTINEL", object()):
|
if callable(interrupt):
|
||||||
try:
|
try:
|
||||||
agent.interrupt("Cancelled by Pheby client")
|
interrupt("Cancelled by Pheby client")
|
||||||
invalidate = getattr(
|
invalidate = getattr(
|
||||||
runner, "_invalidate_session_run_generation", None)
|
runner, "_invalidate_session_run_generation", None)
|
||||||
if callable(invalidate):
|
if callable(invalidate):
|
||||||
@@ -382,13 +539,16 @@ async def cancel_run(conversation_id: str, run_id: Optional[str]) -> bool:
|
|||||||
logger.debug("[pheby] agent interrupt failed", exc_info=True)
|
logger.debug("[pheby] agent interrupt failed", exc_info=True)
|
||||||
if not interrupted and adapter is not None:
|
if not interrupted and adapter is not None:
|
||||||
try:
|
try:
|
||||||
await adapter.interrupt_session_activity(
|
event = getattr(adapter, "_active_sessions", {}).get(session_key)
|
||||||
session_key, conversation_id)
|
if event is not None:
|
||||||
interrupted = True
|
await adapter.interrupt_session_activity(
|
||||||
|
session_key, conversation_id)
|
||||||
|
interrupted = True
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("[pheby] adapter interrupt failed", exc_info=True)
|
logger.debug("[pheby] adapter interrupt failed", exc_info=True)
|
||||||
note_run_finished(conversation_id,
|
if interrupted:
|
||||||
"cancelled" if interrupted else "idle")
|
with _RUN_LOCK:
|
||||||
|
_ACTIVE_RUNS.pop(conversation_id, None)
|
||||||
return interrupted
|
return interrupted
|
||||||
|
|
||||||
|
|
||||||
@@ -399,17 +559,20 @@ async def push_approval(approval_data: Dict[str, Any],
|
|||||||
session_key: str) -> None:
|
session_key: str) -> None:
|
||||||
"""Adapter callback: a dangerous action needs a human decision."""
|
"""Adapter callback: a dangerous action needs a human decision."""
|
||||||
approval_id = uuid.uuid4().hex[:12]
|
approval_id = uuid.uuid4().hex[:12]
|
||||||
from gateway.run import _redact_approval_command
|
# gateway.run redacts the command before calling send_exec_approval.
|
||||||
command = _redact_approval_command(approval_data.get("command", ""))
|
command = approval_data.get("command", "")
|
||||||
choices: List[str] = ["once", "deny"]
|
choices: List[str] = ["once", "deny"]
|
||||||
if approval_data.get("allow_session", True):
|
if approval_data.get("allow_session", True):
|
||||||
choices.insert(1, "session")
|
choices.insert(1, "session")
|
||||||
if approval_data.get("allow_permanent", True):
|
if approval_data.get("allow_permanent", True):
|
||||||
choices.insert(-1, "always")
|
choices.insert(-1, "always")
|
||||||
event = {
|
conversation_id = _conversation_from_session_key(session_key)
|
||||||
|
event: Dict[str, Any] = {
|
||||||
"type": proto.S_APPROVAL_REQUEST,
|
"type": proto.S_APPROVAL_REQUEST,
|
||||||
"approval_id": approval_id,
|
"approval_id": approval_id,
|
||||||
"session_key": session_key,
|
"session_key": session_key,
|
||||||
|
"conversation_id": conversation_id,
|
||||||
|
"run_id": active_run_id(conversation_id),
|
||||||
"command": proto.safe_str(command, 2000),
|
"command": proto.safe_str(command, 2000),
|
||||||
"description": proto.safe_str(
|
"description": proto.safe_str(
|
||||||
approval_data.get("description", ""), 1000),
|
approval_data.get("description", ""), 1000),
|
||||||
@@ -418,7 +581,8 @@ async def push_approval(approval_data: Dict[str, Any],
|
|||||||
}
|
}
|
||||||
_PENDING_APPROVALS[approval_id] = {
|
_PENDING_APPROVALS[approval_id] = {
|
||||||
"session_key": session_key,
|
"session_key": session_key,
|
||||||
"created": asyncio.get_event_loop().time(),
|
"created": time.monotonic(),
|
||||||
|
"event": event,
|
||||||
}
|
}
|
||||||
server = _current_server()
|
server = _current_server()
|
||||||
if server is not None:
|
if server is not None:
|
||||||
@@ -428,11 +592,11 @@ async def push_approval(approval_data: Dict[str, Any],
|
|||||||
async def resolve_approval(approval_id: str, choice: str,
|
async def resolve_approval(approval_id: str, choice: str,
|
||||||
reason: Optional[str]) -> bool:
|
reason: Optional[str]) -> bool:
|
||||||
"""Forward an approval decision to Hermes (tools.approval primitives)."""
|
"""Forward an approval decision to Hermes (tools.approval primitives)."""
|
||||||
pending = _PENDING_APPROVALS.pop(approval_id, None)
|
|
||||||
if pending is None:
|
|
||||||
return False
|
|
||||||
if choice not in ("once", "session", "always", "deny"):
|
if choice not in ("once", "session", "always", "deny"):
|
||||||
return False
|
return False
|
||||||
|
pending = _PENDING_APPROVALS.get(approval_id)
|
||||||
|
if pending is None:
|
||||||
|
return False
|
||||||
try:
|
try:
|
||||||
from tools.approval import resolve_gateway_approval
|
from tools.approval import resolve_gateway_approval
|
||||||
count = await asyncio.to_thread(
|
count = await asyncio.to_thread(
|
||||||
@@ -442,20 +606,14 @@ async def resolve_approval(approval_id: str, choice: str,
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.error("[pheby] approval resolve failed", exc_info=True)
|
logger.error("[pheby] approval resolve failed", exc_info=True)
|
||||||
ok = False
|
ok = False
|
||||||
server = _current_server()
|
if ok:
|
||||||
if server is not None:
|
_PENDING_APPROVALS.pop(approval_id, None)
|
||||||
await server.broadcast({
|
return ok
|
||||||
"type": proto.S_APPROVAL_RESOLVED,
|
|
||||||
"approval_id": approval_id,
|
|
||||||
"choice": choice,
|
|
||||||
"accepted": ok,
|
|
||||||
})
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def fail_stale_approvals(max_age: float = 3600.0) -> None:
|
def fail_stale_approvals(max_age: float = 3600.0) -> None:
|
||||||
"""Drop approval IDs whose Hermes-side gate has surely timed out."""
|
"""Drop approval IDs whose Hermes-side gate has surely timed out."""
|
||||||
now = asyncio.get_event_loop().time()
|
now = time.monotonic()
|
||||||
for aid in [a for a, p in _PENDING_APPROVALS.items()
|
for aid in [a for a, p in _PENDING_APPROVALS.items()
|
||||||
if now - p["created"] > max_age]:
|
if now - p["created"] > max_age]:
|
||||||
_PENDING_APPROVALS.pop(aid, None)
|
_PENDING_APPROVALS.pop(aid, None)
|
||||||
@@ -467,10 +625,13 @@ def fail_stale_approvals(max_age: float = 3600.0) -> None:
|
|||||||
async def push_clarify(clarify_id: str, session_key: str, question: str,
|
async def push_clarify(clarify_id: str, session_key: str, question: str,
|
||||||
choices: Optional[List[str]]) -> None:
|
choices: Optional[List[str]]) -> None:
|
||||||
"""Adapter callback: the agent needs the user to choose."""
|
"""Adapter callback: the agent needs the user to choose."""
|
||||||
event = {
|
conversation_id = _conversation_from_session_key(session_key)
|
||||||
|
event: Dict[str, Any] = {
|
||||||
"type": proto.S_CLARIFY_REQUEST,
|
"type": proto.S_CLARIFY_REQUEST,
|
||||||
"clarify_id": clarify_id,
|
"clarify_id": clarify_id,
|
||||||
"session_key": session_key,
|
"session_key": session_key,
|
||||||
|
"conversation_id": conversation_id,
|
||||||
|
"run_id": active_run_id(conversation_id),
|
||||||
"question": proto.safe_str(question, 2000),
|
"question": proto.safe_str(question, 2000),
|
||||||
"choices": [proto.safe_str(c, 300) for c in choices]
|
"choices": [proto.safe_str(c, 300) for c in choices]
|
||||||
if choices else None,
|
if choices else None,
|
||||||
@@ -479,7 +640,8 @@ async def push_clarify(clarify_id: str, session_key: str, question: str,
|
|||||||
}
|
}
|
||||||
_PENDING_CLARIFIES[clarify_id] = {
|
_PENDING_CLARIFIES[clarify_id] = {
|
||||||
"session_key": session_key,
|
"session_key": session_key,
|
||||||
"created": asyncio.get_event_loop().time(),
|
"created": time.monotonic(),
|
||||||
|
"event": event,
|
||||||
}
|
}
|
||||||
server = _current_server()
|
server = _current_server()
|
||||||
if server is not None:
|
if server is not None:
|
||||||
@@ -488,7 +650,7 @@ async def push_clarify(clarify_id: str, session_key: str, question: str,
|
|||||||
|
|
||||||
async def resolve_clarify(clarify_id: str, response: str) -> bool:
|
async def resolve_clarify(clarify_id: str, response: str) -> bool:
|
||||||
"""Forward a clarification answer to Hermes's clarify primitive."""
|
"""Forward a clarification answer to Hermes's clarify primitive."""
|
||||||
pending = _PENDING_CLARIFIES.pop(clarify_id, None)
|
pending = _PENDING_CLARIFIES.get(clarify_id)
|
||||||
if pending is None:
|
if pending is None:
|
||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
@@ -506,32 +668,33 @@ async def resolve_clarify(clarify_id: str, response: str) -> bool:
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.error("[pheby] clarify resolve failed", exc_info=True)
|
logger.error("[pheby] clarify resolve failed", exc_info=True)
|
||||||
ok = False
|
ok = False
|
||||||
server = _current_server()
|
if ok:
|
||||||
if server is not None:
|
_PENDING_CLARIFIES.pop(clarify_id, None)
|
||||||
await server.broadcast({
|
|
||||||
"type": proto.S_CLARIFY_RESOLVED,
|
|
||||||
"clarify_id": clarify_id,
|
|
||||||
"accepted": bool(ok),
|
|
||||||
})
|
|
||||||
return bool(ok)
|
return bool(ok)
|
||||||
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
# Models & reasoning
|
# Models & reasoning
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
async def models_snapshot() -> Dict[str, Any]:
|
async def models_snapshot(conversation_id: Optional[str] = None) -> Dict[str, Any]:
|
||||||
"""Providers + models Hermes currently exposes (credential-aware)."""
|
"""Providers + models Hermes currently exposes (credential-aware)."""
|
||||||
def _collect() -> Dict[str, Any]:
|
def _collect() -> Dict[str, Any]:
|
||||||
|
from hermes_cli.config import get_compatible_custom_providers
|
||||||
from hermes_cli.model_switch import list_picker_providers
|
from hermes_cli.model_switch import list_picker_providers
|
||||||
cfg = _load_cfg()
|
cfg = _load_cfg()
|
||||||
model_cfg = (cfg.get("model") or {}) if isinstance(cfg, dict) else {}
|
model_cfg = (cfg.get("model") or {}) if isinstance(cfg, dict) else {}
|
||||||
current_model = str(model_cfg.get("default", "") or "")
|
current_model = str(model_cfg.get("default", "") or "")
|
||||||
current_provider = str(model_cfg.get("provider", "openrouter") or "")
|
current_provider = str(model_cfg.get("provider", "openrouter") or "")
|
||||||
|
custom_providers = get_compatible_custom_providers(cfg)
|
||||||
|
excluded = (cfg.get("model_catalog") or {}).get(
|
||||||
|
"excluded_providers", [])
|
||||||
providers = list_picker_providers(
|
providers = list_picker_providers(
|
||||||
current_provider=current_provider,
|
current_provider=current_provider,
|
||||||
|
current_base_url=str(model_cfg.get("base_url", "") or ""),
|
||||||
current_model=current_model,
|
current_model=current_model,
|
||||||
user_providers=cfg.get("providers") if isinstance(cfg, dict) else None,
|
user_providers=cfg.get("providers") if isinstance(cfg, dict) else None,
|
||||||
probe_custom_providers=False, # don't block on offline endpoints
|
custom_providers=custom_providers,
|
||||||
|
excluded_providers=excluded if isinstance(excluded, list) else [],
|
||||||
)
|
)
|
||||||
return {"providers": providers, "current_model": current_model,
|
return {"providers": providers, "current_model": current_model,
|
||||||
"current_provider": current_provider}
|
"current_provider": current_provider}
|
||||||
@@ -542,11 +705,18 @@ async def models_snapshot() -> Dict[str, Any]:
|
|||||||
data = {"providers": [], "current_model": "", "current_provider": "",
|
data = {"providers": [], "current_model": "", "current_provider": "",
|
||||||
"error": "Model catalog unavailable"}
|
"error": "Model catalog unavailable"}
|
||||||
data["supported_reasoning_efforts"] = list(proto.REASONING_EFFORTS)
|
data["supported_reasoning_efforts"] = list(proto.REASONING_EFFORTS)
|
||||||
|
if conversation_id:
|
||||||
|
current = await current_model_snapshot(conversation_id)
|
||||||
|
data["current_model"] = current.get("model", data["current_model"])
|
||||||
|
data["current_provider"] = current.get(
|
||||||
|
"provider", data["current_provider"])
|
||||||
|
data["scope"] = current.get("scope", "global")
|
||||||
data["ts"] = proto.now_iso()
|
data["ts"] = proto.now_iso()
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
async def current_model_snapshot() -> Dict[str, Any]:
|
async def current_model_snapshot(
|
||||||
|
conversation_id: Optional[str] = None) -> Dict[str, Any]:
|
||||||
def _collect() -> Dict[str, Any]:
|
def _collect() -> Dict[str, Any]:
|
||||||
cfg = _load_cfg()
|
cfg = _load_cfg()
|
||||||
model_cfg = (cfg.get("model") or {}) if isinstance(cfg, dict) else {}
|
model_cfg = (cfg.get("model") or {}) if isinstance(cfg, dict) else {}
|
||||||
@@ -556,6 +726,19 @@ async def current_model_snapshot() -> Dict[str, Any]:
|
|||||||
data = await asyncio.to_thread(_collect)
|
data = await asyncio.to_thread(_collect)
|
||||||
except Exception:
|
except Exception:
|
||||||
data = {"model": "", "provider": "", "error": "Config unavailable"}
|
data = {"model": "", "provider": "", "error": "Config unavailable"}
|
||||||
|
data["scope"] = "global"
|
||||||
|
store = _session_store()
|
||||||
|
if conversation_id and store is not None:
|
||||||
|
try:
|
||||||
|
override = await asyncio.to_thread(
|
||||||
|
store.get_model_override, _session_key_for(conversation_id))
|
||||||
|
if override:
|
||||||
|
data["model"] = override.get("model", data["model"])
|
||||||
|
data["provider"] = override.get("provider", data["provider"])
|
||||||
|
data["scope"] = "conversation"
|
||||||
|
except Exception:
|
||||||
|
logger.debug("[pheby] model override read failed", exc_info=True)
|
||||||
|
data["conversation_id"] = conversation_id
|
||||||
data["ts"] = proto.now_iso()
|
data["ts"] = proto.now_iso()
|
||||||
return data
|
return data
|
||||||
|
|
||||||
@@ -567,6 +750,7 @@ async def set_model(model: str, provider: Optional[str],
|
|||||||
return {"ok": False, "code": proto.ERR_BAD_REQUEST,
|
return {"ok": False, "code": proto.ERR_BAD_REQUEST,
|
||||||
"message": "model is required"}
|
"message": "model is required"}
|
||||||
try:
|
try:
|
||||||
|
from hermes_cli.config import get_compatible_custom_providers
|
||||||
from hermes_cli.model_switch import switch_model
|
from hermes_cli.model_switch import switch_model
|
||||||
cfg = _load_cfg()
|
cfg = _load_cfg()
|
||||||
model_cfg = (cfg.get("model") or {}) if isinstance(cfg, dict) else {}
|
model_cfg = (cfg.get("model") or {}) if isinstance(cfg, dict) else {}
|
||||||
@@ -580,7 +764,7 @@ async def set_model(model: str, provider: Optional[str],
|
|||||||
False, # is_global → session-scoped when conversation given
|
False, # is_global → session-scoped when conversation given
|
||||||
provider or "",
|
provider or "",
|
||||||
cfg.get("providers") if isinstance(cfg, dict) else None,
|
cfg.get("providers") if isinstance(cfg, dict) else None,
|
||||||
None,
|
get_compatible_custom_providers(cfg),
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("[pheby] switch_model failed", exc_info=True)
|
logger.error("[pheby] switch_model failed", exc_info=True)
|
||||||
@@ -602,6 +786,11 @@ async def set_model(model: str, provider: Optional[str],
|
|||||||
store = _session_store()
|
store = _session_store()
|
||||||
if conversation_id and store is not None:
|
if conversation_id and store is not None:
|
||||||
try:
|
try:
|
||||||
|
if await asyncio.to_thread(
|
||||||
|
store.peek_session_id,
|
||||||
|
_session_key_for(conversation_id)) is None:
|
||||||
|
return {"ok": False, "code": proto.ERR_CONVERSATION_NOT_FOUND,
|
||||||
|
"message": "Conversation not found"}
|
||||||
await asyncio.to_thread(store.set_model_override,
|
await asyncio.to_thread(store.set_model_override,
|
||||||
_session_key_for(conversation_id),
|
_session_key_for(conversation_id),
|
||||||
override)
|
override)
|
||||||
@@ -624,13 +813,14 @@ async def set_model(model: str, provider: Optional[str],
|
|||||||
|
|
||||||
|
|
||||||
def _save_global_model(model: str, provider: str) -> None:
|
def _save_global_model(model: str, provider: str) -> None:
|
||||||
from hermes_cli.config import load_config, save_config_value
|
from hermes_cli.config import save_config_value
|
||||||
save_config_value("model.default", model)
|
save_config_value("model.default", model)
|
||||||
if provider:
|
if provider:
|
||||||
save_config_value("model.provider", provider)
|
save_config_value("model.provider", provider)
|
||||||
|
|
||||||
|
|
||||||
async def reasoning_snapshot() -> Dict[str, Any]:
|
async def reasoning_snapshot(
|
||||||
|
conversation_id: Optional[str] = None) -> Dict[str, Any]:
|
||||||
def _collect() -> Dict[str, Any]:
|
def _collect() -> Dict[str, Any]:
|
||||||
from hermes_constants import resolve_reasoning_config
|
from hermes_constants import resolve_reasoning_config
|
||||||
cfg = _load_cfg()
|
cfg = _load_cfg()
|
||||||
@@ -647,6 +837,22 @@ async def reasoning_snapshot() -> Dict[str, Any]:
|
|||||||
except Exception:
|
except Exception:
|
||||||
data = {"effort": None, "enabled": None,
|
data = {"effort": None, "enabled": None,
|
||||||
"error": "Config unavailable"}
|
"error": "Config unavailable"}
|
||||||
|
data["scope"] = "global"
|
||||||
|
runner = _runner()
|
||||||
|
if conversation_id and runner is not None:
|
||||||
|
try:
|
||||||
|
cfg = await asyncio.to_thread(
|
||||||
|
runner._resolve_session_reasoning_config,
|
||||||
|
session_key=_session_key_for(conversation_id), model="")
|
||||||
|
if cfg is not None:
|
||||||
|
data = ({"effort": "none", "enabled": False}
|
||||||
|
if cfg.get("enabled") is False else
|
||||||
|
{"effort": cfg.get("effort"), "enabled": True})
|
||||||
|
data["scope"] = "conversation"
|
||||||
|
except Exception:
|
||||||
|
logger.debug("[pheby] reasoning override read failed",
|
||||||
|
exc_info=True)
|
||||||
|
data["conversation_id"] = conversation_id
|
||||||
data["supported_efforts"] = ["none"] + list(proto.REASONING_EFFORTS)
|
data["supported_efforts"] = ["none"] + list(proto.REASONING_EFFORTS)
|
||||||
data["ts"] = proto.now_iso()
|
data["ts"] = proto.now_iso()
|
||||||
return data
|
return data
|
||||||
@@ -664,6 +870,12 @@ async def set_reasoning(effort: str,
|
|||||||
runner = _runner()
|
runner = _runner()
|
||||||
if runner is not None and conversation_id:
|
if runner is not None and conversation_id:
|
||||||
try:
|
try:
|
||||||
|
store = _session_store()
|
||||||
|
if store is None or await asyncio.to_thread(
|
||||||
|
store.peek_session_id,
|
||||||
|
_session_key_for(conversation_id)) is None:
|
||||||
|
return {"ok": False, "code": proto.ERR_CONVERSATION_NOT_FOUND,
|
||||||
|
"message": "Conversation not found"}
|
||||||
await asyncio.to_thread(
|
await asyncio.to_thread(
|
||||||
runner._set_session_reasoning_override,
|
runner._set_session_reasoning_override,
|
||||||
_session_key_for(conversation_id), parsed)
|
_session_key_for(conversation_id), parsed)
|
||||||
@@ -691,23 +903,24 @@ def _load_cfg() -> Dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
# Server context
|
# Process service references
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
_SERVER_CTX: contextvars.ContextVar = contextvars.ContextVar(
|
|
||||||
"pheby_server", default=None)
|
|
||||||
|
|
||||||
|
|
||||||
def set_server(server: Any) -> None:
|
def set_server(server: Any) -> None:
|
||||||
_SERVER_CTX.set(server)
|
global _SERVER
|
||||||
|
with _SERVICE_LOCK:
|
||||||
|
_SERVER = server
|
||||||
|
|
||||||
|
|
||||||
def _current_server() -> Any:
|
def _current_server() -> Any:
|
||||||
return _SERVER_CTX.get()
|
with _SERVICE_LOCK:
|
||||||
|
return _SERVER
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"set_adapter", "set_server", "list_conversations", "conversation_history",
|
"set_adapter", "set_server", "clear_services", "create_conversation",
|
||||||
|
"list_conversations", "conversation_history", "rename_conversation",
|
||||||
"delete_conversation", "send_chat", "cancel_run", "note_run_finished",
|
"delete_conversation", "send_chat", "cancel_run", "note_run_finished",
|
||||||
|
"active_run", "active_run_id", "record_tool_event", "runtime_snapshot",
|
||||||
"push_approval", "resolve_approval", "fail_stale_approvals",
|
"push_approval", "resolve_approval", "fail_stale_approvals",
|
||||||
"push_clarify", "resolve_clarify", "models_snapshot",
|
"push_clarify", "resolve_clarify", "models_snapshot",
|
||||||
"current_model_snapshot", "set_model", "reasoning_snapshot",
|
"current_model_snapshot", "set_model", "reasoning_snapshot",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
name: pheby
|
name: pheby
|
||||||
label: Pheby
|
label: Pheby
|
||||||
kind: platform
|
kind: platform
|
||||||
version: 1.0.0
|
version: 1.0.1
|
||||||
description: >
|
description: >
|
||||||
Pheby platform adapter for Hermes Agent — serves a WebSocket + HTTPS
|
Pheby platform adapter for Hermes Agent — serves a WebSocket + HTTPS
|
||||||
protocol for the Pheby native Android client (Kotlin/Compose) behind a
|
protocol for the Pheby native Android client (Kotlin/Compose) behind a
|
||||||
@@ -37,11 +37,3 @@ optional_env:
|
|||||||
description: "Conversation ID receiving cron/scheduled deliveries by default"
|
description: "Conversation ID receiving cron/scheduled deliveries by default"
|
||||||
prompt: "Home conversation ID (or empty)"
|
prompt: "Home conversation ID (or empty)"
|
||||||
password: false
|
password: false
|
||||||
- name: PHEBY_ALLOWED_USERS
|
|
||||||
description: "Comma-separated allowlist treated as user IDs by the gateway (optional)"
|
|
||||||
prompt: "Allowed user IDs (or empty)"
|
|
||||||
password: false
|
|
||||||
- name: PHEBY_ALLOW_ALL_USERS
|
|
||||||
description: "Allow any authenticated client (dev only)"
|
|
||||||
prompt: "Allow all users? (true/false)"
|
|
||||||
password: false
|
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ ERR_NOT_FOUND = "not_found"
|
|||||||
ERR_CONVERSATION_NOT_FOUND = "conversation_not_found"
|
ERR_CONVERSATION_NOT_FOUND = "conversation_not_found"
|
||||||
ERR_APPROVAL_NOT_FOUND = "approval_not_found"
|
ERR_APPROVAL_NOT_FOUND = "approval_not_found"
|
||||||
ERR_CLARIFY_NOT_FOUND = "clarify_not_found"
|
ERR_CLARIFY_NOT_FOUND = "clarify_not_found"
|
||||||
|
ERR_RUN_ACTIVE = "run_active"
|
||||||
ERR_TOO_LARGE = "too_large"
|
ERR_TOO_LARGE = "too_large"
|
||||||
ERR_RATE_LIMITED = "rate_limited"
|
ERR_RATE_LIMITED = "rate_limited"
|
||||||
ERR_INTERNAL = "internal_error"
|
ERR_INTERNAL = "internal_error"
|
||||||
@@ -172,7 +173,7 @@ __all__ = [
|
|||||||
"ERR_UNAUTHORIZED", "ERR_AUTH_TIMEOUT", "ERR_VERSION_MISMATCH",
|
"ERR_UNAUTHORIZED", "ERR_AUTH_TIMEOUT", "ERR_VERSION_MISMATCH",
|
||||||
"ERR_BAD_REQUEST", "ERR_INVALID_JSON", "ERR_UNKNOWN_TYPE", "ERR_NOT_FOUND",
|
"ERR_BAD_REQUEST", "ERR_INVALID_JSON", "ERR_UNKNOWN_TYPE", "ERR_NOT_FOUND",
|
||||||
"ERR_CONVERSATION_NOT_FOUND", "ERR_APPROVAL_NOT_FOUND",
|
"ERR_CONVERSATION_NOT_FOUND", "ERR_APPROVAL_NOT_FOUND",
|
||||||
"ERR_CLARIFY_NOT_FOUND", "ERR_TOO_LARGE", "ERR_RATE_LIMITED",
|
"ERR_CLARIFY_NOT_FOUND", "ERR_RUN_ACTIVE", "ERR_TOO_LARGE", "ERR_RATE_LIMITED",
|
||||||
"ERR_INTERNAL", "ERR_NOT_IMPLEMENTED",
|
"ERR_INTERNAL", "ERR_NOT_IMPLEMENTED",
|
||||||
"C_HELLO", "C_PING", "C_CONVERSATION_LIST", "C_CONVERSATION_OPEN",
|
"C_HELLO", "C_PING", "C_CONVERSATION_LIST", "C_CONVERSATION_OPEN",
|
||||||
"C_CONVERSATION_CREATE", "C_CONVERSATION_RENAME", "C_CONVERSATION_DELETE",
|
"C_CONVERSATION_CREATE", "C_CONVERSATION_RENAME", "C_CONVERSATION_DELETE",
|
||||||
|
|||||||
+73
-20
@@ -14,8 +14,10 @@ Hermes integration (runs, approvals, clarifications, models) lives in
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import ipaddress
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
|
from urllib.parse import quote
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
@@ -153,13 +155,17 @@ class PhebyServer:
|
|||||||
"message": "Attachment unavailable"}},
|
"message": "Attachment unavailable"}},
|
||||||
status=404)
|
status=404)
|
||||||
desc = self.store.describe(attachment_id) or {}
|
desc = self.store.describe(attachment_id) or {}
|
||||||
safe_name = desc.get("filename", "file.bin")
|
safe_name = str(desc.get("filename", "file.bin"))
|
||||||
|
ascii_name = safe_name.encode("ascii", "replace").decode("ascii") \
|
||||||
|
.replace('"', "_").replace("\\", "_")
|
||||||
|
disposition = (f'attachment; filename="{ascii_name}"; '
|
||||||
|
f"filename*=UTF-8''{quote(safe_name)}")
|
||||||
logger.info("[pheby] attachment download: id=%s bytes=%s",
|
logger.info("[pheby] attachment download: id=%s bytes=%s",
|
||||||
attachment_id, desc.get("size"))
|
attachment_id, desc.get("size"))
|
||||||
return web.FileResponse(
|
return web.FileResponse(
|
||||||
blob,
|
blob,
|
||||||
headers={
|
headers={
|
||||||
"Content-Disposition": f'attachment; filename="{safe_name}"',
|
"Content-Disposition": disposition,
|
||||||
"Content-Type": desc.get("mime_type",
|
"Content-Type": desc.get("mime_type",
|
||||||
"application/octet-stream"),
|
"application/octet-stream"),
|
||||||
},
|
},
|
||||||
@@ -173,7 +179,7 @@ class PhebyServer:
|
|||||||
self._conn_counter += 1
|
self._conn_counter += 1
|
||||||
conn_id = f"c{self._conn_counter}"
|
conn_id = f"c{self._conn_counter}"
|
||||||
|
|
||||||
peer = request.remote or "unknown"
|
peer = self._auth_peer(request)
|
||||||
if self._is_locked_out(peer):
|
if self._is_locked_out(peer):
|
||||||
logger.warning("[pheby] auth lockout active for %s — refusing",
|
logger.warning("[pheby] auth lockout active for %s — refusing",
|
||||||
peer)
|
peer)
|
||||||
@@ -224,6 +230,21 @@ class PhebyServer:
|
|||||||
def _record_auth_failure(self, peer: str) -> None:
|
def _record_auth_failure(self, peer: str) -> None:
|
||||||
self._auth_failures.setdefault(peer, []).append(time.time())
|
self._auth_failures.setdefault(peer, []).append(time.time())
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _auth_peer(request: web.Request) -> str:
|
||||||
|
"""Use Caddy's client IP only when the direct peer is loopback."""
|
||||||
|
direct = request.remote or "unknown"
|
||||||
|
try:
|
||||||
|
if not ipaddress.ip_address(direct).is_loopback:
|
||||||
|
return direct
|
||||||
|
except ValueError:
|
||||||
|
return direct
|
||||||
|
forwarded = request.headers.get("X-Forwarded-For", "").split(",", 1)[0].strip()
|
||||||
|
try:
|
||||||
|
return str(ipaddress.ip_address(forwarded)) if forwarded else direct
|
||||||
|
except ValueError:
|
||||||
|
return direct
|
||||||
|
|
||||||
async def _authenticate(self, client: ClientConnection,
|
async def _authenticate(self, client: ClientConnection,
|
||||||
peer: str) -> bool:
|
peer: str) -> bool:
|
||||||
"""Wait for the hello frame and validate the shared secret."""
|
"""Wait for the hello frame and validate the shared secret."""
|
||||||
@@ -253,7 +274,15 @@ class PhebyServer:
|
|||||||
proto.ERR_UNAUTHORIZED, "Invalid secret"))
|
proto.ERR_UNAUTHORIZED, "Invalid secret"))
|
||||||
return False
|
return False
|
||||||
requested = message.get("protocol_version")
|
requested = message.get("protocol_version")
|
||||||
if requested is not None and int(requested) != proto.PROTOCOL_VERSION:
|
try:
|
||||||
|
requested_version = (proto.PROTOCOL_VERSION if requested is None
|
||||||
|
else int(requested))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
await client.send_json(proto.error_event(
|
||||||
|
proto.ERR_VERSION_MISMATCH,
|
||||||
|
"protocol_version must be an integer"))
|
||||||
|
return False
|
||||||
|
if requested_version != proto.PROTOCOL_VERSION:
|
||||||
await client.send_json(proto.error_event(
|
await client.send_json(proto.error_event(
|
||||||
proto.ERR_VERSION_MISMATCH,
|
proto.ERR_VERSION_MISMATCH,
|
||||||
f"Protocol version mismatch: server={proto.PROTOCOL_VERSION}, "
|
f"Protocol version mismatch: server={proto.PROTOCOL_VERSION}, "
|
||||||
@@ -343,17 +372,20 @@ class PhebyServer:
|
|||||||
proto.ERR_CONVERSATION_NOT_FOUND,
|
proto.ERR_CONVERSATION_NOT_FOUND,
|
||||||
"Conversation not found", request_id))
|
"Conversation not found", request_id))
|
||||||
return
|
return
|
||||||
|
runtime = self.bridge.runtime_snapshot(conversation_id)
|
||||||
await client.send_json({
|
await client.send_json({
|
||||||
"type": proto.S_CONVERSATION_HISTORY,
|
"type": proto.S_CONVERSATION_HISTORY,
|
||||||
"conversation_id": conversation_id,
|
"conversation_id": conversation_id,
|
||||||
"messages": history,
|
"messages": history,
|
||||||
|
"attachments": self.store.list_for_conversation(conversation_id),
|
||||||
|
**runtime,
|
||||||
**({"request_id": request_id} if request_id else {}),
|
**({"request_id": request_id} if request_id else {}),
|
||||||
})
|
})
|
||||||
|
|
||||||
async def _handle_conversation_create(self, client, message, request_id):
|
async def _handle_conversation_create(self, client, message, request_id):
|
||||||
name = message.get("name")
|
name = message.get("name")
|
||||||
cid = await self.router.new_conversation(
|
cid = await self.bridge.create_conversation(
|
||||||
str(name) if name else None)
|
self, str(name) if name else None)
|
||||||
await client.send_json({
|
await client.send_json({
|
||||||
"type": proto.S_CONVERSATION_CREATED,
|
"type": proto.S_CONVERSATION_CREATED,
|
||||||
"conversation_id": cid,
|
"conversation_id": cid,
|
||||||
@@ -375,7 +407,7 @@ class PhebyServer:
|
|||||||
proto.ERR_BAD_REQUEST,
|
proto.ERR_BAD_REQUEST,
|
||||||
"conversation_id and name (≤200 chars) required", request_id))
|
"conversation_id and name (≤200 chars) required", request_id))
|
||||||
return
|
return
|
||||||
ok = await self.router.rename(conversation_id, name)
|
ok = await self.bridge.rename_conversation(conversation_id, name)
|
||||||
if not ok:
|
if not ok:
|
||||||
await client.send_json(proto.error_event(
|
await client.send_json(proto.error_event(
|
||||||
proto.ERR_CONVERSATION_NOT_FOUND, "Conversation not found",
|
proto.ERR_CONVERSATION_NOT_FOUND, "Conversation not found",
|
||||||
@@ -428,17 +460,30 @@ class PhebyServer:
|
|||||||
proto.ERR_TOO_LARGE,
|
proto.ERR_TOO_LARGE,
|
||||||
f"text exceeds {proto.MAX_TEXT_CHARS} chars", request_id))
|
f"text exceeds {proto.MAX_TEXT_CHARS} chars", request_id))
|
||||||
return
|
return
|
||||||
await self.bridge.send_chat(conversation_id, text, client, request_id)
|
await self.bridge.send_chat(
|
||||||
|
self, conversation_id, text, client, request_id)
|
||||||
|
|
||||||
async def _handle_run_cancel(self, client, message, request_id):
|
async def _handle_run_cancel(self, client, message, request_id):
|
||||||
conversation_id = str(message.get("conversation_id", ""))
|
conversation_id = str(message.get("conversation_id", ""))
|
||||||
run_id = message.get("run_id")
|
if not ConversationRouter.is_valid_conversation_id(conversation_id):
|
||||||
ok = await self.bridge.cancel_run(conversation_id, run_id)
|
await client.send_json(proto.error_event(
|
||||||
await client.send_json({
|
proto.ERR_BAD_REQUEST, "Invalid conversation_id", request_id))
|
||||||
"type": proto.S_RUN_FINISHED if ok else proto.S_ERROR,
|
return
|
||||||
**({"run_id": run_id, "status": "cancelled"}
|
requested_run_id = message.get("run_id")
|
||||||
if ok else {"error": {"code": proto.ERR_NOT_FOUND,
|
active = self.bridge.active_run(conversation_id)
|
||||||
"message": "No active run"}}),
|
run_id = active.get("run_id") if active else requested_run_id
|
||||||
|
ok = await self.bridge.cancel_run(conversation_id, requested_run_id)
|
||||||
|
if not ok:
|
||||||
|
await client.send_json(proto.error_event(
|
||||||
|
proto.ERR_NOT_FOUND, "No matching active run", request_id))
|
||||||
|
return
|
||||||
|
if self.adapter is not None:
|
||||||
|
self.adapter._drafts.pop(conversation_id, None)
|
||||||
|
await self.broadcast({
|
||||||
|
"type": proto.S_RUN_FINISHED,
|
||||||
|
"conversation_id": conversation_id,
|
||||||
|
"run_id": run_id,
|
||||||
|
"status": "cancelled",
|
||||||
**({"request_id": request_id} if request_id else {}),
|
**({"request_id": request_id} if request_id else {}),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -453,10 +498,11 @@ class PhebyServer:
|
|||||||
proto.ERR_APPROVAL_NOT_FOUND,
|
proto.ERR_APPROVAL_NOT_FOUND,
|
||||||
"Unknown or already-resolved approval", request_id))
|
"Unknown or already-resolved approval", request_id))
|
||||||
return
|
return
|
||||||
await client.send_json({
|
await self.broadcast({
|
||||||
"type": proto.S_APPROVAL_RESOLVED,
|
"type": proto.S_APPROVAL_RESOLVED,
|
||||||
"approval_id": approval_id,
|
"approval_id": approval_id,
|
||||||
"choice": choice,
|
"choice": choice,
|
||||||
|
"accepted": True,
|
||||||
**({"request_id": request_id} if request_id else {}),
|
**({"request_id": request_id} if request_id else {}),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -470,21 +516,26 @@ class PhebyServer:
|
|||||||
proto.ERR_CLARIFY_NOT_FOUND,
|
proto.ERR_CLARIFY_NOT_FOUND,
|
||||||
"Unknown or already-resolved clarification", request_id))
|
"Unknown or already-resolved clarification", request_id))
|
||||||
return
|
return
|
||||||
await client.send_json({
|
await self.broadcast({
|
||||||
"type": proto.S_CLARIFY_RESOLVED,
|
"type": proto.S_CLARIFY_RESOLVED,
|
||||||
"clarify_id": clarify_id,
|
"clarify_id": clarify_id,
|
||||||
|
"accepted": True,
|
||||||
**({"request_id": request_id} if request_id else {}),
|
**({"request_id": request_id} if request_id else {}),
|
||||||
})
|
})
|
||||||
|
|
||||||
async def _handle_models_list(self, client, message, request_id):
|
async def _handle_models_list(self, client, message, request_id):
|
||||||
snapshot = await self.bridge.models_snapshot()
|
conversation_id = message.get("conversation_id")
|
||||||
|
snapshot = await self.bridge.models_snapshot(
|
||||||
|
str(conversation_id) if conversation_id else None)
|
||||||
snapshot["type"] = proto.S_MODELS_SNAPSHOT
|
snapshot["type"] = proto.S_MODELS_SNAPSHOT
|
||||||
if request_id:
|
if request_id:
|
||||||
snapshot["request_id"] = request_id
|
snapshot["request_id"] = request_id
|
||||||
await client.send_json(snapshot)
|
await client.send_json(snapshot)
|
||||||
|
|
||||||
async def _handle_model_current(self, client, message, request_id):
|
async def _handle_model_current(self, client, message, request_id):
|
||||||
snapshot = await self.bridge.current_model_snapshot()
|
conversation_id = message.get("conversation_id")
|
||||||
|
snapshot = await self.bridge.current_model_snapshot(
|
||||||
|
str(conversation_id) if conversation_id else None)
|
||||||
snapshot["type"] = proto.S_MODEL_CURRENT_SNAPSHOT
|
snapshot["type"] = proto.S_MODEL_CURRENT_SNAPSHOT
|
||||||
if request_id:
|
if request_id:
|
||||||
snapshot["request_id"] = request_id
|
snapshot["request_id"] = request_id
|
||||||
@@ -514,7 +565,9 @@ class PhebyServer:
|
|||||||
if k != "request_id"})
|
if k != "request_id"})
|
||||||
|
|
||||||
async def _handle_reasoning_current(self, client, message, request_id):
|
async def _handle_reasoning_current(self, client, message, request_id):
|
||||||
snapshot = await self.bridge.reasoning_snapshot()
|
conversation_id = message.get("conversation_id")
|
||||||
|
snapshot = await self.bridge.reasoning_snapshot(
|
||||||
|
str(conversation_id) if conversation_id else None)
|
||||||
snapshot["type"] = proto.S_REASONING_SNAPSHOT
|
snapshot["type"] = proto.S_REASONING_SNAPSHOT
|
||||||
if request_id:
|
if request_id:
|
||||||
snapshot["request_id"] = request_id
|
snapshot["request_id"] = request_id
|
||||||
|
|||||||
+239
-13
@@ -13,13 +13,12 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import os
|
import threading
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from aiohttp import web
|
|
||||||
|
|
||||||
# Make the plugin package importable regardless of install layout.
|
# Make the plugin package importable regardless of install layout.
|
||||||
import sys
|
import sys
|
||||||
@@ -156,7 +155,6 @@ def make_server(tmp_path: Path, **overrides) -> PhebyServer:
|
|||||||
**overrides,
|
**overrides,
|
||||||
})
|
})
|
||||||
cfg.secret = overrides.get("secret", cfg.secret or "test-secret-abc123")
|
cfg.secret = overrides.get("secret", cfg.secret or "test-secret-abc123")
|
||||||
from hermes_constants import get_hermes_home # conftest redirects home
|
|
||||||
root = Path(tmp_path) / "attachments"
|
root = Path(tmp_path) / "attachments"
|
||||||
server = PhebyServer(cfg, adapter=FakeAdapter())
|
server = PhebyServer(cfg, adapter=FakeAdapter())
|
||||||
server.store = AttachmentStore(root=root, retention_days=7)
|
server.store = AttachmentStore(root=root, retention_days=7)
|
||||||
@@ -248,6 +246,19 @@ class TestAuth:
|
|||||||
ok = await server._authenticate(client, "peer4")
|
ok = await server._authenticate(client, "peer4")
|
||||||
assert not ok and not client.authenticated
|
assert not ok and not client.authenticated
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_malformed_version_is_structured_error(self, tmp_path):
|
||||||
|
server = make_server(tmp_path)
|
||||||
|
client = FakeClientConnection()
|
||||||
|
client.ws.inbox.put_nowait(proto.encode_message({
|
||||||
|
"type": proto.C_HELLO, "secret": "test-secret-abc123",
|
||||||
|
"protocol_version": {"not": "an integer"}}))
|
||||||
|
ok = await server._authenticate(client, "peer5")
|
||||||
|
assert not ok and not client.authenticated
|
||||||
|
error = client.ws.events()[-1]
|
||||||
|
assert error["type"] == proto.S_ERROR
|
||||||
|
assert error["error"]["code"] == proto.ERR_VERSION_MISMATCH
|
||||||
|
|
||||||
def test_constant_time_equals(self):
|
def test_constant_time_equals(self):
|
||||||
assert constant_time_equals("abc", "abc")
|
assert constant_time_equals("abc", "abc")
|
||||||
assert not constant_time_equals("abc", "abd")
|
assert not constant_time_equals("abc", "abd")
|
||||||
@@ -414,7 +425,8 @@ class TestAttachments:
|
|||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
class TestBridge:
|
class TestBridge:
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_tool_start_event_is_structured_not_text(self, tmp_path):
|
async def test_tool_start_event_is_structured_not_text(self, tmp_path,
|
||||||
|
monkeypatch):
|
||||||
server = make_server(tmp_path)
|
server = make_server(tmp_path)
|
||||||
client = FakeClientConnection()
|
client = FakeClientConnection()
|
||||||
client.authenticated = True
|
client.authenticated = True
|
||||||
@@ -428,8 +440,10 @@ class TestBridge:
|
|||||||
enabled=True, extra={"secret": "test-secret-abc123"}))
|
enabled=True, extra={"secret": "test-secret-abc123"}))
|
||||||
real._pcfg = server.config
|
real._pcfg = server.config
|
||||||
real._server = server
|
real._server = server
|
||||||
|
real._loop = asyncio.get_running_loop()
|
||||||
real._active_sessions = {"agent:main:pheby:dm:conv1": asyncio.Event()}
|
real._active_sessions = {"agent:main:pheby:dm:conv1": asyncio.Event()}
|
||||||
# Tool-call dedup state is created lazily via _tool_state()
|
monkeypatch.setattr(real, "_conversation_for_session_id",
|
||||||
|
lambda _sid: "conv1")
|
||||||
|
|
||||||
from gateway.stream_events import ToolCallChunk
|
from gateway.stream_events import ToolCallChunk
|
||||||
marker = real.format_tool_event(
|
marker = real.format_tool_event(
|
||||||
@@ -437,21 +451,26 @@ class TestBridge:
|
|||||||
args={"query": "cats"}, index=0),
|
args={"query": "cats"}, index=0),
|
||||||
mode="all")
|
mode="all")
|
||||||
assert marker is None # never rendered as chat text
|
assert marker is None # never rendered as chat text
|
||||||
await asyncio.sleep(0) # let ensure_future broadcast run
|
# The display event is intentionally eaten. The authoritative hook
|
||||||
|
# carries the real Hermes session and tool-call IDs.
|
||||||
|
real.on_pre_tool_call(
|
||||||
|
session_id="session-1", tool_name="web_search",
|
||||||
|
tool_call_id="call-1", args={"query": "cats"})
|
||||||
|
await asyncio.sleep(0)
|
||||||
events = client.ws.events()
|
events = client.ws.events()
|
||||||
tool_events = [e for e in events if e["type"] == proto.S_TOOL_EVENT]
|
tool_events = [e for e in events if e["type"] == proto.S_TOOL_EVENT]
|
||||||
assert len(tool_events) == 1
|
assert len(tool_events) == 1
|
||||||
ev = tool_events[0]
|
ev = tool_events[0]
|
||||||
assert ev["tool_name"] == "web_search"
|
assert ev["tool_name"] == "web_search"
|
||||||
assert ev["status"] == "running"
|
assert ev["status"] == "running"
|
||||||
assert ev["tool_call_id"]
|
assert ev["tool_call_id"] == "call-1"
|
||||||
assert ev["conversation_id"] == "conv1"
|
assert ev["conversation_id"] == "conv1"
|
||||||
# No fake prose leaked into a message event
|
# No fake prose leaked into a message event
|
||||||
assert not any(e.get("type") == proto.S_MESSAGE_COMPLETE
|
assert not any(e.get("type") == proto.S_MESSAGE_COMPLETE
|
||||||
for e in events)
|
for e in events)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_post_tool_call_completion(self, tmp_path):
|
async def test_post_tool_call_completion(self, tmp_path, monkeypatch):
|
||||||
server = make_server(tmp_path)
|
server = make_server(tmp_path)
|
||||||
client = FakeClientConnection()
|
client = FakeClientConnection()
|
||||||
client.authenticated = True
|
client.authenticated = True
|
||||||
@@ -462,10 +481,13 @@ class TestBridge:
|
|||||||
real = PhebyAdapter(PlatformConfig(
|
real = PhebyAdapter(PlatformConfig(
|
||||||
enabled=True, extra={"secret": "test-secret-abc123"}))
|
enabled=True, extra={"secret": "test-secret-abc123"}))
|
||||||
real._server = server
|
real._server = server
|
||||||
|
real._loop = asyncio.get_running_loop()
|
||||||
real._active_sessions = {"agent:main:pheby:dm:conv1": asyncio.Event()}
|
real._active_sessions = {"agent:main:pheby:dm:conv1": asyncio.Event()}
|
||||||
|
monkeypatch.setattr(real, "_conversation_for_session_id",
|
||||||
|
lambda _sid: "conv1")
|
||||||
|
|
||||||
real.on_post_tool_call(
|
real.on_post_tool_call(
|
||||||
tool_name="terminal", tool_call_id="call_9",
|
session_id="session-1", tool_name="terminal", tool_call_id="call_9",
|
||||||
status="ok", duration_ms=1234)
|
status="ok", duration_ms=1234)
|
||||||
await asyncio.sleep(0) # let ensure_future run
|
await asyncio.sleep(0) # let ensure_future run
|
||||||
ev = [e for e in client.ws.events()
|
ev = [e for e in client.ws.events()
|
||||||
@@ -475,7 +497,8 @@ class TestBridge:
|
|||||||
assert ev["duration_ms"] == 1234
|
assert ev["duration_ms"] == 1234
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_approval_push_and_resolve_roundtrip(self, tmp_path):
|
async def test_approval_push_and_resolve_roundtrip(self, tmp_path,
|
||||||
|
monkeypatch):
|
||||||
server = make_server(tmp_path)
|
server = make_server(tmp_path)
|
||||||
client = FakeClientConnection()
|
client = FakeClientConnection()
|
||||||
client.authenticated = True
|
client.authenticated = True
|
||||||
@@ -491,11 +514,12 @@ class TestBridge:
|
|||||||
assert req["choices"] == ["once", "session", "always", "deny"]
|
assert req["choices"] == ["once", "session", "always", "deny"]
|
||||||
assert req["description"] == "Destructive command"
|
assert req["description"] == "Destructive command"
|
||||||
|
|
||||||
# Resolve: with no real Hermes queue the resolve call fails-open to
|
|
||||||
# accepted=False but the pending entry must be consumed either way.
|
|
||||||
from pheby import hermes_bridge as hb
|
from pheby import hermes_bridge as hb
|
||||||
|
import tools.approval
|
||||||
|
monkeypatch.setattr(tools.approval, "resolve_gateway_approval",
|
||||||
|
lambda *args, **kwargs: 1)
|
||||||
ok = await hb.resolve_approval(req["approval_id"], "deny", None)
|
ok = await hb.resolve_approval(req["approval_id"], "deny", None)
|
||||||
assert ok is True # pending entry existed; resolution attempted
|
assert ok is True
|
||||||
# Double resolve → not found
|
# Double resolve → not found
|
||||||
ok2 = await hb.resolve_approval(req["approval_id"], "once", None)
|
ok2 = await hb.resolve_approval(req["approval_id"], "once", None)
|
||||||
assert ok2 is False
|
assert ok2 is False
|
||||||
@@ -552,6 +576,117 @@ class TestBridge:
|
|||||||
assert events[0]["type"] == proto.S_RUN_ACCEPTED
|
assert events[0]["type"] == proto.S_RUN_ACCEPTED
|
||||||
assert events[1]["type"] == proto.S_MESSAGE_START
|
assert events[1]["type"] == proto.S_MESSAGE_START
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_chat_handler_passes_server_to_bridge(self, tmp_path):
|
||||||
|
server = make_server(tmp_path)
|
||||||
|
client = FakeClientConnection()
|
||||||
|
client.authenticated = True
|
||||||
|
cid = "a" * 32
|
||||||
|
|
||||||
|
await server._handle_chat_send(client, {
|
||||||
|
"type": proto.C_CHAT_SEND,
|
||||||
|
"conversation_id": cid,
|
||||||
|
"text": "hello through WebSocket",
|
||||||
|
}, "request-handler")
|
||||||
|
|
||||||
|
assert server.adapter.handled[-1].text == "hello through WebSocket"
|
||||||
|
assert server.adapter.handled[-1].source.chat_id == cid
|
||||||
|
assert client.ws.events()[0]["type"] == proto.S_RUN_ACCEPTED
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_chat_dispatch_failure_closes_run(self, tmp_path,
|
||||||
|
monkeypatch):
|
||||||
|
server = make_server(tmp_path)
|
||||||
|
client = FakeClientConnection()
|
||||||
|
client.authenticated = True
|
||||||
|
server._clients["t"] = client
|
||||||
|
cid = "b" * 32
|
||||||
|
|
||||||
|
async def reject(_event):
|
||||||
|
raise RuntimeError("gateway unavailable")
|
||||||
|
|
||||||
|
monkeypatch.setattr(server.adapter, "handle_message", reject)
|
||||||
|
from pheby import hermes_bridge as hb
|
||||||
|
with pytest.raises(RuntimeError, match="gateway unavailable"):
|
||||||
|
await hb.send_chat(server, cid, "hello", client, "request")
|
||||||
|
|
||||||
|
assert hb.active_run(cid) is None
|
||||||
|
assert client.ws.events()[-1]["type"] == proto.S_RUN_FINISHED
|
||||||
|
assert client.ws.events()[-1]["status"] == "failed"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_removes_route_instead_of_resetting(self, tmp_path,
|
||||||
|
monkeypatch):
|
||||||
|
server = make_server(tmp_path)
|
||||||
|
cid = await server.router.new_conversation("Delete me")
|
||||||
|
session_key = f"agent:main:pheby:dm:{cid}"
|
||||||
|
|
||||||
|
class Entry:
|
||||||
|
display_name = "Delete me"
|
||||||
|
|
||||||
|
class Store:
|
||||||
|
def __init__(self):
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._entries = {session_key: Entry()}
|
||||||
|
self.saved = False
|
||||||
|
|
||||||
|
def _ensure_loaded_locked(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _save(self):
|
||||||
|
self.saved = True
|
||||||
|
|
||||||
|
def peek_session_id(self, key):
|
||||||
|
return "session-delete" if key == session_key else None
|
||||||
|
|
||||||
|
class DB:
|
||||||
|
deleted = None
|
||||||
|
|
||||||
|
def delete_session(self, session_id):
|
||||||
|
self.deleted = session_id
|
||||||
|
return True
|
||||||
|
|
||||||
|
store, db = Store(), DB()
|
||||||
|
from pheby import hermes_bridge as hb
|
||||||
|
monkeypatch.setattr(hb, "_session_store", lambda: store)
|
||||||
|
monkeypatch.setattr(hb, "_session_db", lambda: db)
|
||||||
|
|
||||||
|
assert await hb.delete_conversation(cid) is True
|
||||||
|
assert db.deleted == "session-delete"
|
||||||
|
assert session_key not in store._entries
|
||||||
|
assert store.saved is True
|
||||||
|
assert await server.router.get_name(cid) is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_models_snapshot_uses_current_hermes_signature(
|
||||||
|
self, tmp_path, monkeypatch):
|
||||||
|
make_server(tmp_path)
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def fake_list_picker_providers(**kwargs):
|
||||||
|
captured.update(kwargs)
|
||||||
|
return [{"slug": "test", "models": ["m1"]}]
|
||||||
|
|
||||||
|
from pheby import hermes_bridge as hb
|
||||||
|
import hermes_cli.config as hermes_config
|
||||||
|
import hermes_cli.model_switch as model_switch
|
||||||
|
monkeypatch.setattr(hb, "_load_cfg", lambda: {
|
||||||
|
"model": {"default": "m1", "provider": "test"},
|
||||||
|
"providers": {"test": {"base_url": "http://example"}},
|
||||||
|
"model_catalog": {"excluded_providers": ["hidden"]},
|
||||||
|
})
|
||||||
|
monkeypatch.setattr(
|
||||||
|
hermes_config, "get_compatible_custom_providers",
|
||||||
|
lambda _cfg: [{"name": "test", "base_url": "http://example"}])
|
||||||
|
monkeypatch.setattr(
|
||||||
|
model_switch, "list_picker_providers", fake_list_picker_providers)
|
||||||
|
|
||||||
|
snapshot = await hb.models_snapshot()
|
||||||
|
assert snapshot["providers"][0]["slug"] == "test"
|
||||||
|
assert captured["current_model"] == "m1"
|
||||||
|
assert captured["excluded_providers"] == ["hidden"]
|
||||||
|
assert captured["custom_providers"][0]["name"] == "test"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_cancel_run_interrupts_agent(self, tmp_path):
|
async def test_cancel_run_interrupts_agent(self, tmp_path):
|
||||||
server = make_server(tmp_path)
|
server = make_server(tmp_path)
|
||||||
@@ -565,6 +700,7 @@ class TestBridge:
|
|||||||
server.adapter.gateway_runner = runner
|
server.adapter.gateway_runner = runner
|
||||||
|
|
||||||
from pheby import hermes_bridge as hb
|
from pheby import hermes_bridge as hb
|
||||||
|
hb._ACTIVE_RUNS["convX"] = {"run_id": "run-x", "started": 0}
|
||||||
ok = await hb.cancel_run("convX", None)
|
ok = await hb.cancel_run("convX", None)
|
||||||
assert ok is True
|
assert ok is True
|
||||||
assert agent.interrupts # agent.interrupt called, not thread-kill
|
assert agent.interrupts # agent.interrupt called, not thread-kill
|
||||||
@@ -612,6 +748,47 @@ class TestRecovery:
|
|||||||
assert h1["messages"] == h2["messages"]
|
assert h1["messages"] == h2["messages"]
|
||||||
assert h1["conversation_id"] == h2["conversation_id"] == cid
|
assert h1["conversation_id"] == h2["conversation_id"] == cid
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_open_recovers_runtime_and_attachment_state(self, tmp_path):
|
||||||
|
server = make_server(tmp_path)
|
||||||
|
cid = await server.router.new_conversation("Recover")
|
||||||
|
source = Path(tmp_path) / "recovery.png"
|
||||||
|
source.write_bytes(b"\x89PNG recovery")
|
||||||
|
attachment = await server.store.register_file(
|
||||||
|
str(source), conversation_id=cid)
|
||||||
|
|
||||||
|
from pheby import hermes_bridge as hb
|
||||||
|
hb._ACTIVE_RUNS[cid] = {"run_id": "run-recover", "started": 1}
|
||||||
|
hb.record_tool_event(cid, {
|
||||||
|
"type": proto.S_TOOL_EVENT,
|
||||||
|
"conversation_id": cid,
|
||||||
|
"run_id": "run-recover",
|
||||||
|
"tool_call_id": "tool-recover",
|
||||||
|
"tool_name": "terminal",
|
||||||
|
"status": "running",
|
||||||
|
})
|
||||||
|
await hb.push_approval(
|
||||||
|
{"command": "echo hi", "description": "Run command"},
|
||||||
|
f"agent:main:pheby:dm:{cid}")
|
||||||
|
await hb.push_clarify(
|
||||||
|
"clarify-recover", f"agent:main:pheby:dm:{cid}",
|
||||||
|
"Continue?", ["yes", "no"])
|
||||||
|
|
||||||
|
client = FakeClientConnection()
|
||||||
|
client.authenticated = True
|
||||||
|
await server._handle_conversation_open(client, {
|
||||||
|
"type": proto.C_CONVERSATION_OPEN,
|
||||||
|
"conversation_id": cid,
|
||||||
|
}, "recover")
|
||||||
|
snapshot = client.ws.events()[-1]
|
||||||
|
assert snapshot["attachments"][0]["attachment_id"] == \
|
||||||
|
attachment["attachment_id"]
|
||||||
|
assert snapshot["run"]["run_id"] == "run-recover"
|
||||||
|
assert snapshot["tools"][0]["tool_call_id"] == "tool-recover"
|
||||||
|
assert snapshot["approvals"][0]["conversation_id"] == cid
|
||||||
|
assert snapshot["clarifications"][0]["clarify_id"] == \
|
||||||
|
"clarify-recover"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_broadcast_reaches_multiple_clients(self, tmp_path):
|
async def test_broadcast_reaches_multiple_clients(self, tmp_path):
|
||||||
server = make_server(tmp_path)
|
server = make_server(tmp_path)
|
||||||
@@ -756,6 +933,45 @@ class TestLiveServer:
|
|||||||
# Adapter unit checks
|
# Adapter unit checks
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
class TestAdapterUnits:
|
class TestAdapterUnits:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stream_preview_keeps_run_open_until_finalize(self, tmp_path):
|
||||||
|
from pheby.adapter import PhebyAdapter
|
||||||
|
from gateway.config import PlatformConfig
|
||||||
|
from pheby import hermes_bridge as hb
|
||||||
|
|
||||||
|
server = make_server(tmp_path)
|
||||||
|
client = FakeClientConnection()
|
||||||
|
client.authenticated = True
|
||||||
|
server._clients["t"] = client
|
||||||
|
adapter = PhebyAdapter(PlatformConfig(
|
||||||
|
enabled=True, extra={"secret": "test-secret-abc123"}))
|
||||||
|
adapter._server = server
|
||||||
|
adapter._loop = asyncio.get_running_loop()
|
||||||
|
adapter._drafts["conv"] = {"message_id": "draft-run", "text": ""}
|
||||||
|
hb.set_adapter(adapter)
|
||||||
|
hb.set_server(server)
|
||||||
|
hb._ACTIVE_RUNS["conv"] = {"run_id": "run-1", "started": 0}
|
||||||
|
|
||||||
|
first = await adapter.send("conv", "hel", metadata={"expect_edits": True})
|
||||||
|
assert first.message_id == "draft-run"
|
||||||
|
assert hb.active_run_id("conv") == "run-1"
|
||||||
|
assert client.ws.events()[-1]["type"] == proto.S_MESSAGE_DELTA
|
||||||
|
|
||||||
|
final = await adapter.edit_message(
|
||||||
|
"conv", "draft-run", "hello", finalize=True)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
assert final.message_id == "draft-run"
|
||||||
|
assert hb.active_run_id("conv") is None
|
||||||
|
assert [e["type"] for e in client.ws.events()][-2:] == [
|
||||||
|
proto.S_MESSAGE_COMPLETE, proto.S_RUN_FINISHED]
|
||||||
|
|
||||||
|
def test_transport_auth_is_gateway_authorization(self):
|
||||||
|
from pheby.adapter import PhebyAdapter
|
||||||
|
from gateway.config import PlatformConfig
|
||||||
|
adapter = PhebyAdapter(PlatformConfig(
|
||||||
|
enabled=True, extra={"secret": "test-secret-abc123"}))
|
||||||
|
assert adapter.authorization_is_upstream is True
|
||||||
|
|
||||||
def test_redact_args(self):
|
def test_redact_args(self):
|
||||||
from pheby.adapter import _redact_args
|
from pheby.adapter import _redact_args
|
||||||
out = _redact_args({"query": "cats", "api_key": "sk-123",
|
out = _redact_args({"query": "cats", "api_key": "sk-123",
|
||||||
@@ -765,6 +981,16 @@ class TestAdapterUnits:
|
|||||||
assert out["query"] == "cats"
|
assert out["query"] == "cats"
|
||||||
assert out["long"].endswith("…")
|
assert out["long"].endswith("…")
|
||||||
|
|
||||||
|
def test_redact_args_recursively(self):
|
||||||
|
from pheby.adapter import _redact_args
|
||||||
|
out = _redact_args({
|
||||||
|
"headers": {"Authorization": "Bearer secret"},
|
||||||
|
"steps": [{"password": "hunter2", "value": "safe"}],
|
||||||
|
})
|
||||||
|
assert out["headers"]["Authorization"] == "[redacted]"
|
||||||
|
assert out["steps"][0]["password"] == "[redacted]"
|
||||||
|
assert out["steps"][0]["value"] == "safe"
|
||||||
|
|
||||||
def test_sanitize_filename(self):
|
def test_sanitize_filename(self):
|
||||||
from pheby.attachments import AttachmentStore
|
from pheby.attachments import AttachmentStore
|
||||||
assert AttachmentStore._sanitize_filename("../../etc/passwd") == "passwd"
|
assert AttachmentStore._sanitize_filename("../../etc/passwd") == "passwd"
|
||||||
|
|||||||
Reference in New Issue
Block a user