failproofai 1.0.0-beta.2 → 1.0.0-beta.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.next/standalone/.next/BUILD_ID +1 -1
- package/.next/standalone/.next/build-manifest.json +6 -6
- package/.next/standalone/.next/prerender-manifest.json +3 -3
- package/.next/standalone/.next/required-server-files.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_global-error.html +1 -1
- package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
- package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
- package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +3 -3
- package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +3 -3
- package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_not-found.html +1 -1
- package/.next/standalone/.next/server/app/_not-found.rsc +15 -15
- package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +15 -15
- package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
- package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +10 -10
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
- package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/reminder/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/audit/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
- package/.next/standalone/.next/server/app/audit/page.js +1 -1
- package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/index.html +1 -1
- package/.next/standalone/.next/server/app/index.rsc +15 -15
- package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
- package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +4 -4
- package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +10 -10
- package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/policies/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +30 -18
- package/.next/standalone/.next/server/app/policies/page.js +2 -2
- package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/projects/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0id_xf7._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__19120tr._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1fwl2mz._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1myjm-d._.js +1 -1
- package/.next/standalone/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_17k9e3w.js +4 -4
- package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__02r5bgf._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0e8sjqm._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0lkzqax._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1ath6v_._.js → [root-of-the-server]__0ls0gsr._.js} +3 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0qucxyj._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__170799-._.js → [root-of-the-server]__0spkm68._.js} +11 -11
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0u02miy._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1psjybo._.js → [root-of-the-server]__10s_z4l._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0114ewg._.js → [root-of-the-server]__1j4u3yd._.js} +3 -3
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0nb0syg._.js → [root-of-the-server]__1ky3im9._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1zqz4v8._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{node_modules_html-to-image_es_index_0oy_-36.js → node_modules_html-to-image_es_index_0jfts82.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_1ezd2jf._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_1tnuifj._.js +1 -1
- package/.next/standalone/.next/server/middleware-build-manifest.js +6 -6
- package/.next/standalone/.next/server/pages/404.html +1 -1
- package/.next/standalone/.next/server/pages/500.html +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.json +32 -20
- package/.next/standalone/.next/static/chunks/{3s7myk_co0gbh.js → 01h6lfjz1_oie.js} +1 -1
- package/.next/standalone/.next/static/chunks/{03fmihek9n986.js → 06zgzyh3wv1ig.js} +1 -1
- package/.next/standalone/.next/static/chunks/{0fi645si-uiiq.js → 0a7k_x4rp_97z.js} +1 -1
- package/.next/standalone/.next/static/chunks/{2sbelanzvxvv0.js → 0cvd6bcdwchc_.js} +1 -1
- package/.next/standalone/.next/static/chunks/{18rvdw5v72364.js → 13-obgukzg_ke.js} +1 -1
- package/.next/standalone/.next/static/chunks/16p68cybetr31.js +1 -0
- package/.next/standalone/.next/static/chunks/{2j94uwkb4s3qr.js → 1h0_yaoq5f3yx.js} +1 -1
- package/.next/standalone/.next/static/chunks/{3vs4ay36tjds_.js → 29g8ykjk6sx_8.js} +1 -1
- package/.next/standalone/.next/static/chunks/{3hp8ulqknybil.js → 2rqm-klet_gbl.js} +1 -1
- package/.next/standalone/.next/static/chunks/{31feojxhjyjpl.js → 2uvyh8y0naofj.js} +1 -1
- package/.next/standalone/.next/static/chunks/31coz11ia5vc4.js +1 -0
- package/.next/standalone/.next/static/chunks/3p-n_j80zlzi3.js +6 -0
- package/.next/standalone/.next/static/chunks/3zdwnnthucs_1.css +2 -0
- package/.next/standalone/.next/static/chunks/{turbopack-1lsv92x7klwrn.js → turbopack-3diub4ctvn1n0.js} +1 -1
- package/.next/standalone/Cargo.lock +1992 -198
- package/.next/standalone/Cargo.toml +1 -1
- package/.next/standalone/app/actions/get-active-pauses.ts +16 -0
- package/.next/standalone/app/components/pause-notices.tsx +111 -0
- package/.next/standalone/app/policies/hooks-client.tsx +81 -6
- package/.next/standalone/crates/CLOUD_POLICIES.md +136 -0
- package/.next/standalone/crates/failproofaid/Cargo.toml +7 -0
- package/.next/standalone/crates/failproofaid/src/cloud_client.rs +484 -0
- package/.next/standalone/crates/failproofaid/src/cloud_policies.rs +921 -0
- package/.next/standalone/crates/failproofaid/src/main.rs +452 -3
- package/.next/standalone/crates/failproofaid/src/paths.rs +29 -0
- package/.next/standalone/crates/fpai-collect/Cargo.toml +43 -0
- package/.next/standalone/crates/fpai-collect/src/config.rs +412 -0
- package/.next/standalone/crates/fpai-collect/src/cursor.rs +520 -0
- package/.next/standalone/crates/fpai-collect/src/delivery.rs +472 -0
- package/.next/standalone/crates/fpai-collect/src/filetail.rs +484 -0
- package/.next/standalone/crates/fpai-collect/src/health.rs +369 -0
- package/.next/standalone/crates/fpai-collect/src/lib.rs +34 -0
- package/.next/standalone/crates/fpai-collect/src/redact.rs +516 -0
- package/.next/standalone/crates/fpai-collect/src/sources/claude/mod.rs +279 -0
- package/.next/standalone/crates/fpai-collect/src/sources/claude/transform.rs +619 -0
- package/.next/standalone/crates/fpai-collect/src/sources/codex/mod.rs +156 -0
- package/.next/standalone/crates/fpai-collect/src/sources/codex/transform.rs +715 -0
- package/.next/standalone/crates/fpai-collect/src/sources/copilot/mod.rs +158 -0
- package/.next/standalone/crates/fpai-collect/src/sources/copilot/transform.rs +512 -0
- package/.next/standalone/crates/fpai-collect/src/sources/goose/mod.rs +511 -0
- package/.next/standalone/crates/fpai-collect/src/sources/goose/transform.rs +586 -0
- package/.next/standalone/crates/fpai-collect/src/sources/hermes/mod.rs +318 -0
- package/.next/standalone/crates/fpai-collect/src/sources/hermes/transform.rs +579 -0
- package/.next/standalone/crates/fpai-collect/src/sources/hooks/mod.rs +310 -0
- package/.next/standalone/crates/fpai-collect/src/sources/hooks/transform.rs +448 -0
- package/.next/standalone/crates/fpai-collect/src/sources/mod.rs +23 -0
- package/.next/standalone/crates/fpai-collect/src/sources/openclaw/mod.rs +176 -0
- package/.next/standalone/crates/fpai-collect/src/sources/openclaw/transform.rs +500 -0
- package/.next/standalone/crates/fpai-collect/src/sources/opencode/mod.rs +315 -0
- package/.next/standalone/crates/fpai-collect/src/sources/opencode/transform.rs +493 -0
- package/.next/standalone/crates/fpai-collect/src/sources/pi/mod.rs +201 -0
- package/.next/standalone/crates/fpai-collect/src/sources/pi/transform.rs +533 -0
- package/.next/standalone/crates/fpai-collect/src/spool.rs +458 -0
- package/.next/standalone/crates/fpai-collect/src/sqlitepoll.rs +387 -0
- package/.next/standalone/crates/fpai-collect/src/supervisor.rs +423 -0
- package/.next/standalone/crates/fpai-collect/src/uploader.rs +600 -0
- package/.next/standalone/crates/fpai-collect/tests/claude_source.rs +738 -0
- package/.next/standalone/crates/fpai-collect/tests/claude_subagents.rs +393 -0
- package/.next/standalone/crates/fpai-collect/tests/codex_source.rs +975 -0
- package/.next/standalone/crates/fpai-collect/tests/config.rs +286 -0
- package/.next/standalone/crates/fpai-collect/tests/copilot_source.rs +1019 -0
- package/.next/standalone/crates/fpai-collect/tests/delivery.rs +281 -0
- package/.next/standalone/crates/fpai-collect/tests/goose_source.rs +1008 -0
- package/.next/standalone/crates/fpai-collect/tests/hermes_source.rs +1129 -0
- package/.next/standalone/crates/fpai-collect/tests/hooks_source.rs +621 -0
- package/.next/standalone/crates/fpai-collect/tests/openclaw_source.rs +854 -0
- package/.next/standalone/crates/fpai-collect/tests/opencode_source.rs +1478 -0
- package/.next/standalone/crates/fpai-collect/tests/pi_source.rs +1191 -0
- package/.next/standalone/crates/fpai-collect/tests/supervisor.rs +290 -0
- package/.next/standalone/crates/fpai-collect/tests/uploader.rs +506 -0
- package/.next/standalone/package.json +2 -2
- package/.next/standalone/server.js +1 -1
- package/bin/failproofai.mjs +129 -0
- package/dist/cli.mjs +2195 -770
- package/dist/worker.mjs +365 -52
- package/package.json +2 -2
- package/src/hooks/builtin-policies.ts +139 -0
- package/src/hooks/cloud-connection.ts +205 -0
- package/src/hooks/cloud-enrollment-cli.ts +227 -0
- package/src/hooks/cloud-enrollment.ts +173 -0
- package/src/hooks/cloud-managed-policies.ts +138 -0
- package/src/hooks/collector-config.ts +226 -0
- package/src/hooks/configure-wizard.ts +221 -5
- package/src/hooks/custom-hooks-loader.ts +69 -25
- package/src/hooks/daemon-client.ts +18 -0
- package/src/hooks/handler.ts +144 -6
- package/src/hooks/hook-activity-store.ts +50 -0
- package/src/hooks/loader-utils.ts +6 -4
- package/src/hooks/policy-types.ts +35 -0
- package/src/hooks/session-pause-cli.ts +157 -0
- package/src/hooks/session-pause.ts +236 -0
- package/src/hooks/tui.ts +115 -0
- package/.next/standalone/.next/static/chunks/0zbxssxh53n-3.js +0 -1
- package/.next/standalone/.next/static/chunks/2pujpnfx4pws6.css +0 -2
- package/.next/standalone/.next/static/chunks/33u59vf_8xpd-.js +0 -1
- package/.next/standalone/.next/static/chunks/40k1_v4os4cw6.js +0 -6
- /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → pM1veX5Llx4YKEaiFc8Sf}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → pM1veX5Llx4YKEaiFc8Sf}/_clientMiddlewareManifest.js +0 -0
- /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → pM1veX5Llx4YKEaiFc8Sf}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,579 @@
|
|
|
1
|
+
//! Hermes `messages` rows → AgentEye events.
|
|
2
|
+
//!
|
|
3
|
+
//! Every function here is pure in its arguments. The contract on
|
|
4
|
+
//! [`crate::sqlitepoll::SqliteFormat::poll`] is that re-reading the same rows
|
|
5
|
+
//! produces identical bytes, because the server dedups on a content hash —
|
|
6
|
+
//! anything that consulted the clock, a counter, or which poll pass we happen
|
|
7
|
+
//! to be on would turn one turn into a fresh row on every pass.
|
|
8
|
+
//!
|
|
9
|
+
//! Row shapes verified against a real `~/.hermes/state.db` (schema_version 22,
|
|
10
|
+
//! captured from a containerised probe):
|
|
11
|
+
//!
|
|
12
|
+
//! ```text
|
|
13
|
+
//! user role='user', content=<prompt>, finish_reason=NULL
|
|
14
|
+
//! assistant role='assistant', content=<text>, finish_reason='stop'
|
|
15
|
+
//! OR role='assistant', content='', finish_reason='tool_calls',
|
|
16
|
+
//! tool_calls='[{id, call_id, response_item_id, type:"function",
|
|
17
|
+
//! function:{name, arguments:"<JSON *STRING*>"}}, …]'
|
|
18
|
+
//! tool role='tool', content='<JSON string>', tool_call_id, tool_name
|
|
19
|
+
//! ```
|
|
20
|
+
//!
|
|
21
|
+
//! Three details of that shape are load-bearing and each has its own trap:
|
|
22
|
+
//!
|
|
23
|
+
//! * `tool_calls` is an ARRAY that really does carry several entries — the
|
|
24
|
+
//! probe capture has one assistant row issuing two parallel `session_search`
|
|
25
|
+
//! calls, so a transform reading `[0]` would silently drop half the tool
|
|
26
|
+
//! traffic on any parallel turn.
|
|
27
|
+
//! * `function.arguments` is a JSON-encoded **string**, not an object. Passed
|
|
28
|
+
//! through unparsed it reaches the product as one escaped blob per call
|
|
29
|
+
//! instead of queryable fields.
|
|
30
|
+
//! * an assistant row that is calling tools has `content=''` — an empty string,
|
|
31
|
+
//! not NULL. A `content IS NOT NULL` test emits a blank `model_response` for
|
|
32
|
+
//! every tool turn.
|
|
33
|
+
|
|
34
|
+
use std::collections::HashMap;
|
|
35
|
+
|
|
36
|
+
use serde_json::{Map, Value, json};
|
|
37
|
+
|
|
38
|
+
use crate::sqlitepoll::epoch_to_millis;
|
|
39
|
+
|
|
40
|
+
/// Longest derived id component. Matches the other sources so one project's
|
|
41
|
+
/// agent id is the same string whichever agent produced the session.
|
|
42
|
+
const MAX_ID_PART: usize = 48;
|
|
43
|
+
|
|
44
|
+
/// Longest goal string lifted from a session's opening prompt.
|
|
45
|
+
const MAX_GOAL_CHARS: usize = 1024;
|
|
46
|
+
|
|
47
|
+
/// Sub-second offset given to `agent_end`, so it sorts after every content
|
|
48
|
+
/// event that shares its millisecond. The server's tie-break is a random id,
|
|
49
|
+
/// so without this a session can appear to end before its own last turn.
|
|
50
|
+
const END_INDEX: u32 = 999;
|
|
51
|
+
|
|
52
|
+
/// One `messages` row, as far as this source cares.
|
|
53
|
+
///
|
|
54
|
+
/// The reasoning columns (`reasoning`, `reasoning_content`, `reasoning_details`,
|
|
55
|
+
/// `codex_reasoning_items`, `codex_message_items`) and `api_content` are
|
|
56
|
+
/// deliberately absent: they are provider-shaped scratch space Hermes rewrites
|
|
57
|
+
/// in place, and shipping them would multiply the payload for content already
|
|
58
|
+
/// present in `content`/`tool_calls`.
|
|
59
|
+
#[derive(Debug, Clone, Default)]
|
|
60
|
+
pub struct MessageRow {
|
|
61
|
+
pub id: i64,
|
|
62
|
+
pub session_id: String,
|
|
63
|
+
pub role: String,
|
|
64
|
+
pub content: Option<String>,
|
|
65
|
+
pub tool_call_id: Option<String>,
|
|
66
|
+
pub tool_calls: Option<String>,
|
|
67
|
+
pub tool_name: Option<String>,
|
|
68
|
+
/// REAL epoch **seconds** with sub-second precision, e.g. `1785744251.65`.
|
|
69
|
+
pub timestamp: f64,
|
|
70
|
+
pub finish_reason: Option<String>,
|
|
71
|
+
/// 0 once a rewind or a compaction has taken the row out of the model's
|
|
72
|
+
/// context. The row itself survives, which is why we still ship it.
|
|
73
|
+
pub active: i64,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/// The `sessions` row a message belongs to.
|
|
77
|
+
///
|
|
78
|
+
/// `user_id`, `chat_id` and `session_key` are deliberately not carried: on a
|
|
79
|
+
/// gateway session they are the Slack/Telegram account and channel identifiers,
|
|
80
|
+
/// i.e. personal data with no analytic use here. `system_prompt` is skipped too
|
|
81
|
+
/// — it is kilobytes, identical across every session of an install, and can
|
|
82
|
+
/// carry operator secrets pasted into `SOUL.md`.
|
|
83
|
+
#[derive(Debug, Clone, Default)]
|
|
84
|
+
pub struct SessionMeta {
|
|
85
|
+
pub source: Option<String>,
|
|
86
|
+
pub model: Option<String>,
|
|
87
|
+
pub cwd: Option<String>,
|
|
88
|
+
pub git_branch: Option<String>,
|
|
89
|
+
pub git_repo_root: Option<String>,
|
|
90
|
+
pub chat_type: Option<String>,
|
|
91
|
+
pub title: Option<String>,
|
|
92
|
+
pub started_at: Option<f64>,
|
|
93
|
+
pub ended_at: Option<f64>,
|
|
94
|
+
pub end_reason: Option<String>,
|
|
95
|
+
pub input_tokens: Option<i64>,
|
|
96
|
+
pub output_tokens: Option<i64>,
|
|
97
|
+
/// Lowest and highest surviving `messages.id` for this session. What makes
|
|
98
|
+
/// `agent_start` / `agent_end` fire exactly once without the poller having
|
|
99
|
+
/// to remember anything between passes — see [`agent_start`].
|
|
100
|
+
pub first_message_id: Option<i64>,
|
|
101
|
+
pub last_message_id: Option<i64>,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/// Format epoch milliseconds as the RFC3339-with-microseconds string ingest
|
|
105
|
+
/// requires, offsetting by `index` microseconds.
|
|
106
|
+
///
|
|
107
|
+
/// Microseconds are forced rather than left to RFC3339's variable precision so
|
|
108
|
+
/// every source emits one shape and the hook stream and the session stream sort
|
|
109
|
+
/// against each other on a shared timeline.
|
|
110
|
+
///
|
|
111
|
+
/// The offset is what keeps several events derived from ONE row in order: the
|
|
112
|
+
/// server sorts by `(ts, random id)`, so an assistant row's two parallel tool
|
|
113
|
+
/// calls would otherwise come back shuffled. It is capped at 999 µs so it can
|
|
114
|
+
/// never carry into the next millisecond and reorder an event past a genuinely
|
|
115
|
+
/// later row.
|
|
116
|
+
pub fn to_rfc3339_micros(epoch_ms: i64, index: u32) -> Option<String> {
|
|
117
|
+
let nanos = (epoch_ms as i128) * 1_000_000 + (index.min(999) as i128) * 1_000;
|
|
118
|
+
let dt = time::OffsetDateTime::from_unix_timestamp_nanos(nanos).ok()?;
|
|
119
|
+
const FMT: &[time::format_description::BorrowedFormatItem<'_>] = time::macros::format_description!(
|
|
120
|
+
"[year]-[month]-[day]T[hour]:[minute]:[second].[subsecond digits:6]Z"
|
|
121
|
+
);
|
|
122
|
+
dt.format(FMT).ok()
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/// Make a derived id component safe and bounded.
|
|
126
|
+
pub fn sanitize_id_part(s: &str) -> String {
|
|
127
|
+
let cleaned: String = s
|
|
128
|
+
.chars()
|
|
129
|
+
.map(|c| {
|
|
130
|
+
if c.is_ascii_alphanumeric() || c == '.' || c == '_' {
|
|
131
|
+
c
|
|
132
|
+
} else {
|
|
133
|
+
'-'
|
|
134
|
+
}
|
|
135
|
+
})
|
|
136
|
+
.collect();
|
|
137
|
+
cleaned
|
|
138
|
+
.split('-')
|
|
139
|
+
.filter(|p| !p.is_empty())
|
|
140
|
+
.collect::<Vec<_>>()
|
|
141
|
+
.join("-")
|
|
142
|
+
.chars()
|
|
143
|
+
.take(MAX_ID_PART)
|
|
144
|
+
.collect()
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/// Last path component of a working directory, sanitized.
|
|
148
|
+
fn project_name(cwd: &str) -> Option<String> {
|
|
149
|
+
let last = cwd
|
|
150
|
+
.trim_end_matches('/')
|
|
151
|
+
.rsplit('/')
|
|
152
|
+
.find(|p| !p.is_empty())?;
|
|
153
|
+
let cleaned = sanitize_id_part(last);
|
|
154
|
+
(!cleaned.is_empty()).then_some(cleaned)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/// The agent id a session's events are filed under.
|
|
158
|
+
///
|
|
159
|
+
/// Hermes has BOTH shapes and each needs its own answer:
|
|
160
|
+
///
|
|
161
|
+
/// * A **CLI** session has a real `cwd` (5/5 in the probe capture, alongside
|
|
162
|
+
/// `git_branch` and `git_repo_root`), so it groups by project exactly like
|
|
163
|
+
/// Claude and the hook source do — which is the whole point: hook events and
|
|
164
|
+
/// session events for one run must land under a single agent rather than two
|
|
165
|
+
/// that look unrelated.
|
|
166
|
+
/// * A **gateway** session (Slack, Telegram, cron) genuinely has NULL `cwd`.
|
|
167
|
+
/// There is no project to group by, so it falls back to the transport in
|
|
168
|
+
/// `source`. Grouping every gateway session under one bare `hermes` would
|
|
169
|
+
/// merge Slack and Telegram traffic into an agent nobody can act on.
|
|
170
|
+
///
|
|
171
|
+
/// `fallback` — the collector's configured default — is used only when the
|
|
172
|
+
/// session row itself is missing, which means a message referencing a deleted
|
|
173
|
+
/// session.
|
|
174
|
+
pub fn agent_id(meta: Option<&SessionMeta>, fallback: &str) -> String {
|
|
175
|
+
let Some(meta) = meta else {
|
|
176
|
+
return fallback.to_string();
|
|
177
|
+
};
|
|
178
|
+
if let Some(project) = meta.cwd.as_deref().and_then(project_name) {
|
|
179
|
+
return format!("hermes-{project}");
|
|
180
|
+
}
|
|
181
|
+
if let Some(source) = meta
|
|
182
|
+
.source
|
|
183
|
+
.as_deref()
|
|
184
|
+
.map(sanitize_id_part)
|
|
185
|
+
.filter(|s| !s.is_empty())
|
|
186
|
+
{
|
|
187
|
+
return format!("hermes-{source}");
|
|
188
|
+
}
|
|
189
|
+
fallback.to_string()
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/// The row's timestamp as epoch milliseconds, or `None` when it is implausible.
|
|
193
|
+
///
|
|
194
|
+
/// [`epoch_to_millis`] reports anything below ~2001 as 0, which would render as
|
|
195
|
+
/// 1970-01-01 and park the event at the head of every timeline it lands on.
|
|
196
|
+
/// That is worse than not shipping it: one corrupt row would silently reorder
|
|
197
|
+
/// a whole session. The watermark advances past such a row regardless, so it is
|
|
198
|
+
/// dropped once rather than retried forever.
|
|
199
|
+
fn row_millis(row: &MessageRow) -> Option<i64> {
|
|
200
|
+
let ms = epoch_to_millis(row.timestamp);
|
|
201
|
+
(ms > 0).then_some(ms)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/// The envelope every emitted event carries.
|
|
205
|
+
///
|
|
206
|
+
/// `hermes_row_id` is the dedup discriminator. Two identical turns — the same
|
|
207
|
+
/// prompt asked twice in one session — must hash differently or the server
|
|
208
|
+
/// collapses them into one row; `messages.id` is an `INTEGER PRIMARY KEY
|
|
209
|
+
/// AUTOINCREMENT`, so it is unique and never reused even after a rewind
|
|
210
|
+
/// deletes the rows around it.
|
|
211
|
+
fn base(
|
|
212
|
+
kind: &str,
|
|
213
|
+
epoch_ms: i64,
|
|
214
|
+
index: u32,
|
|
215
|
+
row: &MessageRow,
|
|
216
|
+
agent_id: &str,
|
|
217
|
+
environment: &str,
|
|
218
|
+
) -> Option<Map<String, Value>> {
|
|
219
|
+
let mut m = Map::new();
|
|
220
|
+
m.insert(
|
|
221
|
+
"timestamp".into(),
|
|
222
|
+
json!(to_rfc3339_micros(epoch_ms, index)?),
|
|
223
|
+
);
|
|
224
|
+
m.insert("session_id".into(), json!(row.session_id));
|
|
225
|
+
m.insert("agent_id".into(), json!(agent_id));
|
|
226
|
+
m.insert("type".into(), json!(kind));
|
|
227
|
+
m.insert("environment".into(), json!(environment));
|
|
228
|
+
m.insert("hermes_row_id".into(), json!(row.id));
|
|
229
|
+
if index > 0 {
|
|
230
|
+
m.insert("hermes_block_index".into(), json!(index));
|
|
231
|
+
}
|
|
232
|
+
// Only ever present on a row a rewind or compaction has taken out of the
|
|
233
|
+
// model's context. Emitted as the exception rather than on every row so the
|
|
234
|
+
// common case costs no bytes, and so the product can explain why a message
|
|
235
|
+
// the user can see was not in the prompt that followed it.
|
|
236
|
+
if row.active == 0 {
|
|
237
|
+
m.insert("hermes_active".into(), json!(false));
|
|
238
|
+
}
|
|
239
|
+
Some(m)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/// Attach the session-level context every event of a session should carry.
|
|
243
|
+
fn with_session_context(m: &mut Map<String, Value>, meta: Option<&SessionMeta>) {
|
|
244
|
+
let Some(meta) = meta else { return };
|
|
245
|
+
if let Some(s) = meta.source.as_deref().filter(|s| !s.is_empty()) {
|
|
246
|
+
m.insert("hermes_source".into(), json!(s));
|
|
247
|
+
}
|
|
248
|
+
if let Some(c) = meta.cwd.as_deref().filter(|s| !s.is_empty()) {
|
|
249
|
+
m.insert("hermes_cwd".into(), json!(c));
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/// The session's `agent_start`, emitted from its FIRST surviving message row.
|
|
254
|
+
///
|
|
255
|
+
/// Firing it off `first_message_id` rather than "the first time we see this
|
|
256
|
+
/// session" is what makes it survive the poller having no memory between
|
|
257
|
+
/// passes: the answer is a pure function of what is in the database, so a
|
|
258
|
+
/// re-read produces the identical event and the server dedups it, whereas an
|
|
259
|
+
/// "emit on first sight" rule would ship a new start every time the daemon
|
|
260
|
+
/// restarted.
|
|
261
|
+
///
|
|
262
|
+
/// The timestamp comes from the session's own `started_at`, not the message's:
|
|
263
|
+
/// Hermes stamps `started_at` when the session opens, which on the capture is
|
|
264
|
+
/// 12–34 ms before the user's first prompt lands. Using the message time would
|
|
265
|
+
/// report every session as starting late and hide the gateway's own latency.
|
|
266
|
+
pub fn agent_start(
|
|
267
|
+
row: &MessageRow,
|
|
268
|
+
meta: &SessionMeta,
|
|
269
|
+
agent_id: &str,
|
|
270
|
+
environment: &str,
|
|
271
|
+
) -> Option<Value> {
|
|
272
|
+
let ms = meta
|
|
273
|
+
.started_at
|
|
274
|
+
.map(epoch_to_millis)
|
|
275
|
+
.filter(|ms| *ms > 0)
|
|
276
|
+
.or_else(|| row_millis(row))?;
|
|
277
|
+
let mut m = base("agent_start", ms, 0, row, agent_id, environment)?;
|
|
278
|
+
|
|
279
|
+
// The session's goal is its opening human prompt. Only read off the first
|
|
280
|
+
// row so this stays a pure function of the rows in hand — searching forward
|
|
281
|
+
// for a user row would give a different answer depending on where the batch
|
|
282
|
+
// happened to be cut.
|
|
283
|
+
if row.role == "user"
|
|
284
|
+
&& let Some(text) = row.content.as_deref().filter(|t| !t.is_empty())
|
|
285
|
+
{
|
|
286
|
+
m.insert(
|
|
287
|
+
"goal".into(),
|
|
288
|
+
json!(text.chars().take(MAX_GOAL_CHARS).collect::<String>()),
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
if let Some(model) = meta.model.as_deref().filter(|s| !s.is_empty()) {
|
|
292
|
+
m.insert("model".into(), json!(model));
|
|
293
|
+
}
|
|
294
|
+
if let Some(b) = meta.git_branch.as_deref().filter(|s| !s.is_empty()) {
|
|
295
|
+
m.insert("hermes_git_branch".into(), json!(b));
|
|
296
|
+
}
|
|
297
|
+
if let Some(r) = meta.git_repo_root.as_deref().filter(|s| !s.is_empty()) {
|
|
298
|
+
m.insert("hermes_git_repo_root".into(), json!(r));
|
|
299
|
+
}
|
|
300
|
+
if let Some(t) = meta.chat_type.as_deref().filter(|s| !s.is_empty()) {
|
|
301
|
+
m.insert("hermes_chat_type".into(), json!(t));
|
|
302
|
+
}
|
|
303
|
+
if let Some(t) = meta.title.as_deref().filter(|s| !s.is_empty()) {
|
|
304
|
+
m.insert("hermes_title".into(), json!(t));
|
|
305
|
+
}
|
|
306
|
+
with_session_context(&mut m, Some(meta));
|
|
307
|
+
Some(Value::Object(m))
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/// The session's `agent_end`, emitted from its LAST surviving message row.
|
|
311
|
+
///
|
|
312
|
+
/// Gated on `ended_at` being set, so a session still in flight does not get an
|
|
313
|
+
/// end event on every poll that touches its newest row. The cost is a narrow
|
|
314
|
+
/// race: Hermes writes the final assistant message and only then `UPDATE`s
|
|
315
|
+
/// `sessions.ended_at` — 11 ms later on the capture. A poll landing inside that
|
|
316
|
+
/// window consumes the last row while the session still looks open, and no
|
|
317
|
+
/// later row will ever arrive to re-trigger it, so that session ends up with no
|
|
318
|
+
/// `agent_end`. Accepted over the alternative, which is ending every live
|
|
319
|
+
/// session on every pass.
|
|
320
|
+
pub fn agent_end(
|
|
321
|
+
row: &MessageRow,
|
|
322
|
+
meta: &SessionMeta,
|
|
323
|
+
agent_id: &str,
|
|
324
|
+
environment: &str,
|
|
325
|
+
) -> Option<Value> {
|
|
326
|
+
let ms = meta
|
|
327
|
+
.ended_at
|
|
328
|
+
.map(epoch_to_millis)
|
|
329
|
+
.filter(|ms| *ms > 0)
|
|
330
|
+
.or_else(|| row_millis(row))?;
|
|
331
|
+
let mut m = base("agent_end", ms, END_INDEX, row, agent_id, environment)?;
|
|
332
|
+
if let Some(reason) = meta.end_reason.as_deref().filter(|s| !s.is_empty()) {
|
|
333
|
+
m.insert("hermes_end_reason".into(), json!(reason));
|
|
334
|
+
}
|
|
335
|
+
if let Some(n) = meta.input_tokens.filter(|n| *n > 0) {
|
|
336
|
+
m.insert("input_tokens".into(), json!(n));
|
|
337
|
+
}
|
|
338
|
+
if let Some(n) = meta.output_tokens.filter(|n| *n > 0) {
|
|
339
|
+
m.insert("output_tokens".into(), json!(n));
|
|
340
|
+
}
|
|
341
|
+
with_session_context(&mut m, Some(meta));
|
|
342
|
+
Some(Value::Object(m))
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/// One `messages` row to the events it yields.
|
|
346
|
+
///
|
|
347
|
+
/// `pending` carries tool-call ids to names within a poll so a result is not a
|
|
348
|
+
/// blank row; see [`tool_events`] for why it is not the only source of the name.
|
|
349
|
+
pub fn message_events(
|
|
350
|
+
row: &MessageRow,
|
|
351
|
+
meta: Option<&SessionMeta>,
|
|
352
|
+
agent_id: &str,
|
|
353
|
+
environment: &str,
|
|
354
|
+
pending: &mut HashMap<String, String>,
|
|
355
|
+
) -> Vec<Value> {
|
|
356
|
+
let Some(ms) = row_millis(row) else {
|
|
357
|
+
return Vec::new();
|
|
358
|
+
};
|
|
359
|
+
match row.role.as_str() {
|
|
360
|
+
"user" => user_events(row, meta, agent_id, environment, ms),
|
|
361
|
+
"assistant" => assistant_events(row, meta, agent_id, environment, ms, pending),
|
|
362
|
+
"tool" => tool_events(row, meta, agent_id, environment, ms, pending),
|
|
363
|
+
// Hermes only writes user/assistant/tool today. An unknown role is
|
|
364
|
+
// skipped rather than guessed at: a wrong event type is harder to
|
|
365
|
+
// notice than a missing one, and a new role would need its own mapping
|
|
366
|
+
// anyway.
|
|
367
|
+
_ => Vec::new(),
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/// A `user` row is a prompt.
|
|
372
|
+
fn user_events(
|
|
373
|
+
row: &MessageRow,
|
|
374
|
+
meta: Option<&SessionMeta>,
|
|
375
|
+
agent_id: &str,
|
|
376
|
+
environment: &str,
|
|
377
|
+
ms: i64,
|
|
378
|
+
) -> Vec<Value> {
|
|
379
|
+
let Some(text) = row.content.as_deref().filter(|t| !t.is_empty()) else {
|
|
380
|
+
return Vec::new();
|
|
381
|
+
};
|
|
382
|
+
let Some(mut m) = base("model_request", ms, 0, row, agent_id, environment) else {
|
|
383
|
+
return Vec::new();
|
|
384
|
+
};
|
|
385
|
+
// The model is on the SESSION, never on the message. The server builds this
|
|
386
|
+
// row's summary from the model alone, so without lifting it here every
|
|
387
|
+
// prompt renders as an empty row.
|
|
388
|
+
if let Some(model) = meta
|
|
389
|
+
.and_then(|s| s.model.as_deref())
|
|
390
|
+
.filter(|s| !s.is_empty())
|
|
391
|
+
{
|
|
392
|
+
m.insert("model".into(), json!(model));
|
|
393
|
+
}
|
|
394
|
+
m.insert(
|
|
395
|
+
"messages".into(),
|
|
396
|
+
json!([{ "role": "user", "content": text }]),
|
|
397
|
+
);
|
|
398
|
+
with_session_context(&mut m, meta);
|
|
399
|
+
vec![Value::Object(m)]
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/// An `assistant` row is text, or tool calls, or (defensively) both.
|
|
403
|
+
///
|
|
404
|
+
/// The probe capture never has both — `finish_reason` is either `stop` with
|
|
405
|
+
/// text or `tool_calls` with an empty `content` — but handling the pair costs
|
|
406
|
+
/// nothing and a provider that starts emitting a preamble alongside its calls
|
|
407
|
+
/// would otherwise lose the preamble silently.
|
|
408
|
+
fn assistant_events(
|
|
409
|
+
row: &MessageRow,
|
|
410
|
+
meta: Option<&SessionMeta>,
|
|
411
|
+
agent_id: &str,
|
|
412
|
+
environment: &str,
|
|
413
|
+
ms: i64,
|
|
414
|
+
pending: &mut HashMap<String, String>,
|
|
415
|
+
) -> Vec<Value> {
|
|
416
|
+
let model = meta
|
|
417
|
+
.and_then(|s| s.model.as_deref())
|
|
418
|
+
.filter(|s| !s.is_empty());
|
|
419
|
+
let mut out = Vec::new();
|
|
420
|
+
|
|
421
|
+
if let Some(text) = row.content.as_deref().filter(|t| !t.is_empty())
|
|
422
|
+
&& let Some(mut m) = base("model_response", ms, 0, row, agent_id, environment)
|
|
423
|
+
{
|
|
424
|
+
m.insert("role".into(), json!("assistant"));
|
|
425
|
+
m.insert("content".into(), json!(text));
|
|
426
|
+
if let Some(model) = model {
|
|
427
|
+
m.insert("model".into(), json!(model));
|
|
428
|
+
}
|
|
429
|
+
if let Some(fr) = row.finish_reason.as_deref().filter(|s| !s.is_empty()) {
|
|
430
|
+
m.insert("hermes_finish_reason".into(), json!(fr));
|
|
431
|
+
}
|
|
432
|
+
with_session_context(&mut m, meta);
|
|
433
|
+
out.push(Value::Object(m));
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
for (i, call) in parse_tool_calls(row.tool_calls.as_deref())
|
|
437
|
+
.iter()
|
|
438
|
+
.enumerate()
|
|
439
|
+
{
|
|
440
|
+
// Index 0 belongs to the text event whether or not it exists, so a call's
|
|
441
|
+
// sub-second offset does not shift depending on whether the model also
|
|
442
|
+
// spoke — the same row must always produce the same bytes.
|
|
443
|
+
let index = (i + 1) as u32;
|
|
444
|
+
let Some(mut m) = base("tool_use", ms, index, row, agent_id, environment) else {
|
|
445
|
+
continue;
|
|
446
|
+
};
|
|
447
|
+
let function = call.get("function");
|
|
448
|
+
let name = function
|
|
449
|
+
.and_then(|f| f.get("name"))
|
|
450
|
+
.and_then(|n| n.as_str())
|
|
451
|
+
.unwrap_or("tool");
|
|
452
|
+
// `id` and `call_id` are the same value on every row of the capture;
|
|
453
|
+
// `id` wins and `call_id` is the fallback in case a provider adapter
|
|
454
|
+
// fills only one. Failing both, an id derived from (row, index) keeps
|
|
455
|
+
// two parallel identical calls from hash-collapsing into one row.
|
|
456
|
+
let id = call
|
|
457
|
+
.get("id")
|
|
458
|
+
.and_then(|x| x.as_str())
|
|
459
|
+
.or_else(|| call.get("call_id").and_then(|x| x.as_str()))
|
|
460
|
+
.map(str::to_string)
|
|
461
|
+
.unwrap_or_else(|| format!("hermes-{}-{i}", row.id));
|
|
462
|
+
pending.insert(id.clone(), name.to_string());
|
|
463
|
+
m.insert("tool_name".into(), json!(name));
|
|
464
|
+
m.insert("tool_call_id".into(), json!(id));
|
|
465
|
+
m.insert("input".into(), call_input(function));
|
|
466
|
+
if let Some(model) = model {
|
|
467
|
+
m.insert("model".into(), json!(model));
|
|
468
|
+
}
|
|
469
|
+
with_session_context(&mut m, meta);
|
|
470
|
+
out.push(Value::Object(m));
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
out
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/// A `tool` row is one call's result.
|
|
477
|
+
fn tool_events(
|
|
478
|
+
row: &MessageRow,
|
|
479
|
+
meta: Option<&SessionMeta>,
|
|
480
|
+
agent_id: &str,
|
|
481
|
+
environment: &str,
|
|
482
|
+
ms: i64,
|
|
483
|
+
pending: &mut HashMap<String, String>,
|
|
484
|
+
) -> Vec<Value> {
|
|
485
|
+
let Some(mut m) = base("tool_result", ms, 0, row, agent_id, environment) else {
|
|
486
|
+
return Vec::new();
|
|
487
|
+
};
|
|
488
|
+
if let Some(id) = row.tool_call_id.as_deref().filter(|s| !s.is_empty()) {
|
|
489
|
+
m.insert("tool_call_id".into(), json!(id));
|
|
490
|
+
// The call's name wins because it is what the model actually invoked;
|
|
491
|
+
// Hermes also stores `tool_name` on the result row, which is the only
|
|
492
|
+
// thing that saves the pairing when the batch was cut between the call
|
|
493
|
+
// and its result — then `pending` is empty and the result would
|
|
494
|
+
// otherwise be a blank row in the product.
|
|
495
|
+
if let Some(name) = pending
|
|
496
|
+
.get(id)
|
|
497
|
+
.map(String::as_str)
|
|
498
|
+
.or(row.tool_name.as_deref())
|
|
499
|
+
.filter(|s| !s.is_empty())
|
|
500
|
+
{
|
|
501
|
+
m.insert("tool_name".into(), json!(name));
|
|
502
|
+
}
|
|
503
|
+
} else if let Some(name) = row.tool_name.as_deref().filter(|s| !s.is_empty()) {
|
|
504
|
+
m.insert("tool_name".into(), json!(name));
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
if let Some(output) = row.content.as_deref() {
|
|
508
|
+
m.insert("output".into(), json!(output));
|
|
509
|
+
if let Some(err) = tool_error(output) {
|
|
510
|
+
m.insert("error".into(), json!(err));
|
|
511
|
+
m.insert("error_type".into(), json!("hermes_tool_error"));
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
with_session_context(&mut m, meta);
|
|
515
|
+
vec![Value::Object(m)]
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/// Parse the `tool_calls` column into its entries.
|
|
519
|
+
///
|
|
520
|
+
/// Returns empty for NULL, for the empty string, and for anything that is not
|
|
521
|
+
/// a JSON array — a malformed column must cost this row's tool calls, not the
|
|
522
|
+
/// whole poll.
|
|
523
|
+
fn parse_tool_calls(raw: Option<&str>) -> Vec<Value> {
|
|
524
|
+
let Some(raw) = raw.filter(|s| !s.trim().is_empty()) else {
|
|
525
|
+
return Vec::new();
|
|
526
|
+
};
|
|
527
|
+
match serde_json::from_str::<Value>(raw) {
|
|
528
|
+
Ok(Value::Array(items)) => items,
|
|
529
|
+
_ => Vec::new(),
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/// A call's `input`, unwrapping the JSON-encoded `arguments` string.
|
|
534
|
+
///
|
|
535
|
+
/// Hermes stores OpenAI's wire shape verbatim, where `arguments` is a STRING
|
|
536
|
+
/// containing JSON: `{"function":{"arguments":"{\"command\": \"ls /work\"}"}}`.
|
|
537
|
+
/// Shipping that string straight through gives the product one escaped blob per
|
|
538
|
+
/// tool call instead of the fields a policy or a query can reach.
|
|
539
|
+
///
|
|
540
|
+
/// A string that does not parse is kept verbatim under `arguments` rather than
|
|
541
|
+
/// dropped: a truncated argument list is still the best evidence available of
|
|
542
|
+
/// what the agent tried to run.
|
|
543
|
+
fn call_input(function: Option<&Value>) -> Value {
|
|
544
|
+
match function.and_then(|f| f.get("arguments")) {
|
|
545
|
+
Some(Value::String(s)) => {
|
|
546
|
+
serde_json::from_str::<Value>(s).unwrap_or_else(|_| json!({ "arguments": s }))
|
|
547
|
+
}
|
|
548
|
+
Some(other) => other.clone(),
|
|
549
|
+
None => json!({}),
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/// The error a tool result reports, if it reports one.
|
|
554
|
+
///
|
|
555
|
+
/// Hermes tool results are JSON strings whose shape is the tool's own. Two
|
|
556
|
+
/// signals are unambiguous across the capture: a non-null `error`
|
|
557
|
+
/// (`{"error": "around_message_id 1 not in session_id …", "success": false}`)
|
|
558
|
+
/// and an explicit `success: false`.
|
|
559
|
+
///
|
|
560
|
+
/// A non-zero `exit_code` is deliberately NOT treated as an error even though
|
|
561
|
+
/// `terminal` reports one: `grep` finding nothing exits 1, and flagging that as
|
|
562
|
+
/// a failed tool call would fill the product with false alarms that are
|
|
563
|
+
/// indistinguishable from real ones.
|
|
564
|
+
fn tool_error(content: &str) -> Option<String> {
|
|
565
|
+
let obj = serde_json::from_str::<Value>(content).ok()?;
|
|
566
|
+
let obj = obj.as_object()?;
|
|
567
|
+
if let Some(err) = obj.get("error") {
|
|
568
|
+
match err {
|
|
569
|
+
Value::Null => {}
|
|
570
|
+
Value::String(s) if s.is_empty() => {}
|
|
571
|
+
Value::String(s) => return Some(s.clone()),
|
|
572
|
+
other => return Some(other.to_string()),
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
if obj.get("success").and_then(Value::as_bool) == Some(false) {
|
|
576
|
+
return Some(content.to_string());
|
|
577
|
+
}
|
|
578
|
+
None
|
|
579
|
+
}
|