Compare commits

..

925 Commits

Author SHA1 Message Date
Alexander Whitestone
ed250b1ca8 test(#798): Strengthen parallel tool calling tests + fix flaky concurrent tests
All checks were successful
Lint / lint (pull_request) Successful in 10s
- Add TestAIAgentConcurrentExecution with 8 integration tests exercising
  _execute_tool_calls_concurrent through AIAgent for 2/3/4-tool batches,
  pass-rate reporting, and Gemma 4-style read patterns.
- Fix test_malformed_json_args_forces_sequential: use JSON array '[1,2,3]'
  instead of unrepairable garbage now that repair_and_load_json handles
  most malformed input.
- Fix test_concurrent_handles_tool_error: replace racy call_count list
  with deterministic failure based on tool_call_id to eliminate flaky
  failures under ThreadPoolExecutor.

Closes #798
2026-04-22 01:34:24 -04:00
16eab5d503 Merge pull request '[claude] A2A auth — mutual TLS between fleet agents (#806)' (#948) from claude/issue-806 into main
All checks were successful
Lint / lint (push) Successful in 13s
Merge PR #948: A2A auth — mutual TLS between fleet agents (#806)
2026-04-22 03:19:42 +00:00
c7a2d439c1 Merge pull request 'feat: The Sovereign Scavenger — Automated Tech Debt Recovery' (#974) from feat/sovereign-scavenger-1776827259631 into main
All checks were successful
Lint / lint (push) Successful in 12s
2026-04-22 03:14:14 +00:00
8ad8520bd2 Merge pull request 'feat: Execution Safety Sentry — GOFAI Risk Analysis' (#973) from feat/static-analyzer-gofai-1776826921747 into main
Some checks failed
Lint / lint (push) Has been cancelled
2026-04-22 03:14:07 +00:00
9c7c88823f Merge pull request 'feat: Local Inference Story — Freeing the fleet from cloud dependency' (#972) from feat/local-inference-bridge-1776826896029 into main
Some checks failed
Lint / lint (push) Has been cancelled
2026-04-22 03:14:03 +00:00
aa45e02238 Merge pull request 'feat: GOFAI Semantic Sentry — Deterministic code verification' (#971) from feat/symbolic-verify-gofai-1776826842170 into main
Some checks failed
Lint / lint (push) Has been cancelled
2026-04-22 03:14:01 +00:00
3266c39e8e feat: Sovereign Scavenger — Turning tech debt into actionable backlog
All checks were successful
Lint / lint (pull_request) Successful in 18s
2026-04-22 03:07:40 +00:00
93a855d4e3 feat: Static Risk Analyzer (GOFAI) for execution safety
All checks were successful
Lint / lint (pull_request) Successful in 8s
2026-04-22 03:02:02 +00:00
5a0bdb556e feat: Local Inference Bridge — Bypassing cloud for local tasks
All checks were successful
Lint / lint (pull_request) Successful in 17s
2026-04-22 03:01:37 +00:00
d619d279f8 feat: Symbolic Sentry (GOFAI) for deterministic code audits
All checks were successful
Lint / lint (pull_request) Successful in 15s
2026-04-22 03:00:44 +00:00
Alexander Whitestone
4214082fb6 feat: A2A auth — mutual TLS between fleet agents
All checks were successful
Lint / lint (pull_request) Successful in 8s
Implements mTLS for securing agent-to-agent communication in the Hermes
fleet. Fixes #806.

Changes:
- scripts/gen_fleet_ca.sh: generate a self-signed Fleet CA (4096-bit RSA,
  10-year validity) that signs all agent certificates
- scripts/gen_agent_cert.sh: generate per-agent certs (Timmy, Allegro,
  Ezra) signed by the fleet CA with SAN entries and clientAuth/serverAuth
  extended key usage
- agent/mtls.py: new module providing:
  - build_server_ssl_context() — TLS_SERVER context with CERT_REQUIRED,
    enforces client cert against Fleet CA
  - build_client_ssl_context() — TLS_CLIENT context for outbound A2A calls
  - MTLSMiddleware — ASGI middleware that rejects unauthenticated requests
    to A2A routes (/.well-known/agent-card*, /api/agent-card, /a2a/) with
    HTTP 403 when mTLS is enabled
  - is_mtls_configured() — checks HERMES_MTLS_CERT/KEY/CA env vars
- hermes_cli/web_server.py: wire MTLSMiddleware into the FastAPI app;
  pass SSL context to uvicorn when HERMES_MTLS_* env vars are set so
  the server runs TLS with mandatory client cert verification
- ansible/roles/hermes_mtls/: Ansible role to distribute Fleet CA cert,
  agent cert, and agent key to fleet nodes; writes an env file with
  HERMES_MTLS_* vars and restarts the hermes-gateway service
- ansible/fleet_mtls.yml: fleet-wide playbook referencing the role for
  Timmy, Allegro, and Ezra nodes
- tests/test_mtls.py: 15 tests covering is_mtls_configured, SSL context
  creation with real cryptography-generated certs, and MTLSMiddleware
  (unauthorized agent rejected → 403, authorized agent accepted → 200)

mTLS is opt-in: set HERMES_MTLS_CERT, HERMES_MTLS_KEY, and HERMES_MTLS_CA
to enable. When unset, the server behaves exactly as before.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 18:04:00 -04:00
Alexander Whitestone
ac28444bf2 feat: add A2AMTLSServer routing API, A2AMTLSClient, and expand tests to 20 (#806)
All checks were successful
Lint / lint (pull_request) Successful in 9s
Builds on the existing A2AServer / build_*_ssl_context foundation:

- agent/a2a_mtls.py:
  - Add A2AMTLSServer: routing-based HTTPS server with add_route() and
    context-manager (__enter__/__exit__) lifecycle support
  - Add A2AMTLSClient: fleet-cert-presenting HTTP client with .get() / .post()
  - Widen imports (json, Callable, Dict, urlopen)

- tests/agent/test_a2a_mtls.py:
  - Fix datetime.utcnow() deprecation — use datetime.now(timezone.utc)
  - Add TestA2AMTLSServerAndClient (9 tests): routing GET/POST, 404,
    context-manager stop, rogue-cert rejection, A2AMTLSClient, concurrency
  - Total: 11 → 20 passing tests

Refs #806
2026-04-21 15:21:10 -04:00
Alexander Whitestone
91faf6f956 feat: A2A auth — mutual TLS between fleet agents
All checks were successful
Lint / lint (pull_request) Successful in 10s
Implements mutual TLS for secure agent-to-agent communication (#806).

- scripts/gen_fleet_ca.sh: generate fleet CA (4096-bit RSA, 10-year)
- scripts/gen_agent_cert.sh: per-agent cert signed by fleet CA (timmy, allegro, ezra)
- agent/a2a_mtls.py: A2AServer requiring client cert verification (CERT_REQUIRED),
  build_server_ssl_context / build_client_ssl_context helpers, server_from_env()
- ansible/roles/fleet_mtls_certs/: distribute CA + per-agent certs to fleet nodes,
  write /etc/hermes/a2a.env, notify hermes-a2a service on change
- ansible/fleet_mtls.yml + ansible/inventory/fleet.ini.example: playbook + example inventory
- tests/agent/test_a2a_mtls.py: 11 tests — authorized agent accepted (200/202),
  self-signed cert rejected, no-cert rejected, lifecycle, env-var wiring

Fixes #806

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 13:28:28 -04:00
a2a40429bd Merge pull request '[claude] Poka-yoke: auto-revert incomplete skill edits (#923)' (#946) from claude/issue-923 into main
All checks were successful
Lint / lint (push) Successful in 10s
2026-04-21 16:38:24 +00:00
ee61c5fa9d Merge pull request 'feat: Add queue health check script' (#912) from feat/queue-health-check into main
All checks were successful
Lint / lint (push) Successful in 34s
2026-04-21 15:37:59 +00:00
Alexander Whitestone
1fece10569 feat: poka-yoke auto-revert for incomplete skill edits (#923)
All checks were successful
Lint / lint (pull_request) Successful in 32s
Implement a transactional write-validate-commit-or-rollback pattern for
all skill_manage write operations (edit, patch, write_file):

- _backup_skill_file: timestamped .bak.{ts} snapshot before every write
- _validate_written_file: re-reads from disk after write to catch truncation,
  encoding errors, and broken YAML frontmatter
- _revert_from_backup: restores original content (or removes the corrupted
  file) on any validation failure
- _cleanup_old_backups: prunes to MAX_BACKUPS_PER_FILE (3) after success;
  failed edits keep their .bak file as a debugging aid

Also fixes pre-existing issue where _patch_skill error returns lacked a
`suggestion` field expected by test_skill_manager_error_context.py tests.

Adds 21 tests in test_skill_manager_autorevert.py covering every component
and an end-to-end simulation of mid-write failure + auto-revert.

Fixes #923

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 11:37:55 -04:00
46668505bc Merge pull request 'feat: tool fixation detection — break repetitive loops (#886)' (#914) from fix/886 into main
Some checks failed
Lint / lint (push) Has been cancelled
2026-04-21 15:35:08 +00:00
cac0c8224e Merge pull request 'fix: circuit breaker for error cascading (2.33x amplification)' (#927) from fix/885-circuit-breaker into main
Some checks failed
Lint / lint (push) Has been cancelled
2026-04-21 15:35:04 +00:00
f38a64455d Merge pull request '[claude] Gateway config debt: add validation tests and API_SERVER_KEY warning (#892)' (#915) from claude/issue-892 into main
Some checks failed
Lint / lint (push) Has been cancelled
2026-04-21 15:33:19 +00:00
1b35a5a0d2 Merge pull request 'feat: Poka-yoke — hardcoded path guard (#921)' (#928) from fix/921-hardcoded-path-guard into main
Some checks failed
Lint / lint (push) Has been cancelled
2026-04-21 15:33:14 +00:00
9172131b25 Merge pull request 'docs: tool investigation report from awesome-ai-tools (#926)' (#931) from fix/926 into main
Some checks failed
Lint / lint (push) Has been cancelled
2026-04-21 15:33:12 +00:00
407eab3331 Merge pull request 'feat: session deterministic seeding & marathon limits' (#919) from feat/session-management-1776700585635 into main
Some checks failed
Lint / lint (push) Has been cancelled
2026-04-21 15:29:44 +00:00
cf090a966d Merge pull request 'fix: Poka-yoke — detect and block tool hallucination before API calls (#922)' (#935) from fix/922 into main
Some checks failed
Lint / lint (push) Has been cancelled
2026-04-21 15:29:35 +00:00
b65be9b12c Merge pull request '[claude] Add tool investigation report: top 5 awesome-ai-tools recommendations (#926)' (#936) from claude/issue-926 into main
Some checks failed
Lint / lint (push) Has been cancelled
2026-04-21 15:29:32 +00:00
3c1cff255e Merge pull request 'ci: integrate hardcoded path linter into CI workflow' (#938) from fix/865-ci-path-linter into main
Some checks failed
Lint / lint (push) Has been cancelled
2026-04-21 15:29:30 +00:00
690d100afc Merge pull request 'feat: Poka-yoke token budget — progressive context overflow guard (#925)' (#943) from burn/925-1776770102 into main
Some checks failed
Nix / nix (macos-latest) (push) Waiting to run
Docker Build and Publish / build-and-push (push) Has been skipped
Nix / nix (ubuntu-latest) (push) Failing after 5s
Tests / e2e (push) Successful in 5m8s
Tests / test (push) Failing after 30m13s
2026-04-21 15:29:02 +00:00
c6f0831738 Merge pull request 'feat: Python syntax validation before execute_code (#913)' (#917) from fix/913-syntax-validation into main
Some checks failed
Docker Build and Publish / build-and-push (push) Has been cancelled
Nix / nix (macos-latest) (push) Has been cancelled
Nix / nix (ubuntu-latest) (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / e2e (push) Has been cancelled
2026-04-21 15:27:05 +00:00
30773ac1f9 Merge pull request 'fix: Path validation before read_file — poka-yoke (#887)' (#911) from fix/887-path-validation-read-file into main
Some checks failed
Docker Build and Publish / build-and-push (push) Has been cancelled
Nix / nix (macos-latest) (push) Has been cancelled
Nix / nix (ubuntu-latest) (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / e2e (push) Has been cancelled
2026-04-21 15:26:55 +00:00
feb24bd08c Merge pull request 'feat: Block silent credential exposure in tool outputs (#839)' (#910) from fix/839-1776403070 into main
Some checks failed
Docker Build and Publish / build-and-push (push) Has been cancelled
Nix / nix (macos-latest) (push) Has been cancelled
Nix / nix (ubuntu-latest) (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / e2e (push) Has been cancelled
2026-04-21 15:26:47 +00:00
bc55f40505 Merge pull request 'feat: time-aware model routing for cron jobs (#889)' (#909) from fix/889 into main
Some checks failed
Docker Build and Publish / build-and-push (push) Has been cancelled
Nix / nix (macos-latest) (push) Has been cancelled
Nix / nix (ubuntu-latest) (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / e2e (push) Has been cancelled
2026-04-21 15:26:43 +00:00
2adc72335e Merge pull request 'fix: profile session isolation — tag and filter by profile' (#907) from fix/891-profile-isolation into main
Some checks failed
Docker Build and Publish / build-and-push (push) Has been cancelled
Nix / nix (macos-latest) (push) Has been cancelled
Nix / nix (ubuntu-latest) (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / e2e (push) Has been cancelled
2026-04-21 15:26:39 +00:00
ab32670464 Merge pull request 'feat: Poka-yoke — detect and block tool hallucination before API calls (#922)' (#944) from burn/922-1776770102 into main
Some checks failed
Docker Build and Publish / build-and-push (push) Has been cancelled
Nix / nix (macos-latest) (push) Has been cancelled
Nix / nix (ubuntu-latest) (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / e2e (push) Has been cancelled
2026-04-21 15:23:56 +00:00
bfc0231297 Merge pull request 'docs: holographic + vector hybrid memory architecture (#879)' (#942) from fix/879 into main
Some checks failed
Docker Build and Publish / build-and-push (push) Has been cancelled
Nix / nix (macos-latest) (push) Has been cancelled
Nix / nix (ubuntu-latest) (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / e2e (push) Has been cancelled
2026-04-21 15:23:49 +00:00
cf2b09cf2f Merge pull request 'docs: emotional presence patterns for crisis support (#880)' (#941) from fix/880 into main
Some checks failed
Docker Build and Publish / build-and-push (push) Has been cancelled
Nix / nix (macos-latest) (push) Has been cancelled
Nix / nix (ubuntu-latest) (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / e2e (push) Has been cancelled
2026-04-21 15:23:45 +00:00
719bb537c0 Merge pull request 'feat: provider preflight validation before session start (#924)' (#932) from fix/924 into main
Some checks failed
Docker Build and Publish / build-and-push (push) Has been cancelled
Nix / nix (macos-latest) (push) Has been cancelled
Nix / nix (ubuntu-latest) (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / e2e (push) Has been cancelled
2026-04-21 15:23:02 +00:00
0bcbcf19ac Merge pull request 'feat: time-aware model routing for cron jobs #889' (#906) from fix/time-aware-routing-889 into main
Some checks failed
Docker Build and Publish / build-and-push (push) Has been cancelled
Nix / nix (macos-latest) (push) Has been cancelled
Nix / nix (ubuntu-latest) (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / e2e (push) Has been cancelled
2026-04-21 15:22:37 +00:00
27d2f2ca0e Merge pull request 'feat: Prevent context window overflow via proactive token counting (#838)' (#905) from fix/838-1776402240 into main
Some checks failed
Docker Build and Publish / build-and-push (push) Has been cancelled
Nix / nix (macos-latest) (push) Has been cancelled
Nix / nix (ubuntu-latest) (push) Has been cancelled
Tests / e2e (push) Has been cancelled
Tests / test (push) Has been cancelled
2026-04-21 15:22:31 +00:00
7e7dcfa345 Merge pull request 'fix: Gateway config validation and fallback fixes (#892)' (#904) from fix/892 into main
Some checks failed
Docker Build and Publish / build-and-push (push) Has been cancelled
Nix / nix (macos-latest) (push) Has been cancelled
Nix / nix (ubuntu-latest) (push) Has been cancelled
Tests / e2e (push) Has been cancelled
Tests / test (push) Has been cancelled
2026-04-21 15:22:22 +00:00
ba0e614446 Merge pull request 'feat: integrate 988 Suicide & Crisis Lifeline — automatic crisis escalation (#673)' (#903) from feat/673 into main
Some checks failed
Docker Build and Publish / build-and-push (push) Has been cancelled
Nix / nix (macos-latest) (push) Has been cancelled
Nix / nix (ubuntu-latest) (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / e2e (push) Has been cancelled
2026-04-21 15:22:17 +00:00
4f5e641c92 Merge pull request 'fix: kill 9 dead cron jobs — audit and cleanup script' (#902) from fix/890-dead-cron-jobs into main
Some checks failed
Docker Build and Publish / build-and-push (push) Has been cancelled
Nix / nix (macos-latest) (push) Has been cancelled
Nix / nix (ubuntu-latest) (push) Has been cancelled
Tests / e2e (push) Has been cancelled
Tests / test (push) Has been cancelled
2026-04-21 15:22:15 +00:00
d61bd141f9 feat: add poka-yoke validation to non-execute_code dispatch (#922)
Some checks failed
Docker Build and Publish / build-and-push (pull_request) Has been skipped
Contributor Attribution Check / check-attribution (pull_request) Failing after 32s
Supply Chain Audit / Scan PR for supply chain risks (pull_request) Successful in 32s
Tests / e2e (pull_request) Successful in 3m5s
Tests / test (pull_request) Failing after 36m26s
2026-04-21 12:01:57 +00:00
a4058af238 feat: wire poka-yoke validation into tool dispatch (#922) 2026-04-21 12:00:20 +00:00
08432a5618 test: poka-yoke validation tests (#922) 2026-04-21 11:59:26 +00:00
a875c6ed91 feat: poka-yoke tool call validation firewall (#922) 2026-04-21 11:59:25 +00:00
07c5b5b83d test: add token budget poka-yoke tests (#925)
Some checks failed
Contributor Attribution Check / check-attribution (pull_request) Failing after 44s
Docker Build and Publish / build-and-push (pull_request) Has been skipped
Supply Chain Audit / Scan PR for supply chain risks (pull_request) Successful in 45s
Tests / test (pull_request) Failing after 25m21s
Tests / e2e (pull_request) Successful in 3m18s
2026-04-21 11:41:39 +00:00
ba56567631 docs: holographic + vector hybrid memory architecture (#879)
Some checks failed
Docker Build and Publish / build-and-push (pull_request) Has been skipped
Supply Chain Audit / Scan PR for supply chain risks (pull_request) Successful in 45s
Tests / test (pull_request) Failing after 14m3s
Tests / e2e (pull_request) Successful in 1m53s
2026-04-21 11:41:31 +00:00
8ac26f54a5 feat: token budget with progressive poka-yoke thresholds (#925) 2026-04-21 11:40:39 +00:00
b807972d05 docs: emotional presence patterns for crisis support (#880)
Some checks failed
Docker Build and Publish / build-and-push (pull_request) Has been skipped
Supply Chain Audit / Scan PR for supply chain risks (pull_request) Successful in 52s
Tests / e2e (pull_request) Successful in 3m53s
Tests / test (pull_request) Failing after 53m34s
2026-04-21 11:37:57 +00:00
6b5a6db668 ci: add Gitea Actions lint workflow
All checks were successful
Lint / lint (pull_request) Successful in 15s
Part of #865. Runs hardcoded path linter on every push/PR.
2026-04-21 11:37:33 +00:00
b702249c12 ci: add hardcoded path linter to CI workflow
Closes #865

Runs scripts/lint_hardcoded_paths.py as a CI check.
Uses continue-on-error for now since the linter may have false positives.
2026-04-21 11:37:31 +00:00
Alexander Whitestone
8023c9b8f2 docs: add tool investigation report for top 5 awesome-ai-tools recommendations
Some checks failed
Docker Build and Publish / build-and-push (pull_request) Has been skipped
Supply Chain Audit / Scan PR for supply chain risks (pull_request) Successful in 55s
Tests / e2e (pull_request) Successful in 3m56s
Tests / test (pull_request) Failing after 54m0s
Persists the research report from issue #926 as a markdown file following
the existing convention of research_*.md files in the repo. Documents the
top 5 tool recommendations (LiteLLM, Mem0, RAGFlow, LiteRT-LM, Claude-Mem)
with integration effort, impact scores, and phased implementation plan.

Refs #926
2026-04-21 07:26:44 -04:00
6eeee39c10 test(#922): Add tests for tool hallucination detection
Some checks failed
Contributor Attribution Check / check-attribution (pull_request) Failing after 1m15s
Docker Build and Publish / build-and-push (pull_request) Has been skipped
Supply Chain Audit / Scan PR for supply chain risks (pull_request) Successful in 1m8s
Tests / e2e (pull_request) Successful in 3m44s
Tests / test (pull_request) Failing after 1h9m15s
Tests for validation firewall:
- Unknown tool detection
- Missing required params
- Wrong type detection
- Hallucination patterns
- Rejection stats

Refs #922
2026-04-21 05:38:54 +00:00
b2d2d2c650 fix(#922): Poka-yoke — detect and block tool hallucination
Validation firewall between LLM tool-call output and execution:
1. Unknown tool names rejected
2. Malformed parameters caught
3. Missing required arguments detected
4. Hallucination patterns detected

All rejections logged with model provenance.
Agent receives rejection as tool result for self-correction.

Resolves #922
2026-04-21 05:38:22 +00:00
bdd0f2709b feat: provider preflight validation before session start (#924)
Some checks failed
Contributor Attribution Check / check-attribution (pull_request) Failing after 47s
Docker Build and Publish / build-and-push (pull_request) Has been skipped
Supply Chain Audit / Scan PR for supply chain risks (pull_request) Successful in 52s
Tests / test (pull_request) Failing after 30m48s
Tests / e2e (pull_request) Successful in 2m9s
2026-04-21 04:48:57 +00:00
a9cbf7d69f docs: tool investigation report from awesome-ai-tools (#926)
Some checks failed
Docker Build and Publish / build-and-push (pull_request) Has been skipped
Supply Chain Audit / Scan PR for supply chain risks (pull_request) Successful in 36s
Tests / e2e (pull_request) Successful in 2m56s
Tests / test (pull_request) Failing after 34m20s
2026-04-21 04:45:03 +00:00
4cdda8701d feat: integrate hardcoded path guard into tool dispatch
Some checks failed
Docker Build and Publish / build-and-push (pull_request) Has been skipped
Contributor Attribution Check / check-attribution (pull_request) Failing after 32s
Supply Chain Audit / Scan PR for supply chain risks (pull_request) Successful in 32s
Tests / e2e (pull_request) Successful in 2m56s
Tests / test (pull_request) Failing after 1h1m7s
2026-04-21 00:31:01 +00:00
a80d30b342 feat: add pre-commit hook for hardcoded path detection 2026-04-21 00:29:33 +00:00
f098cf8c4a feat: add hardcoded path guard module (#921)
- Detects /Users/, /home/, ~/ in tool arguments
- Source code scanner for CI/pre-commit
- Runtime guard for tool dispatch
- noqa: hardcoded-path-ok escape hatch

Closes #921
2026-04-21 00:29:12 +00:00
30509b9c7c test: circuit breaker tests
Some checks failed
Docker Build and Publish / build-and-push (pull_request) Has been skipped
Contributor Attribution Check / check-attribution (pull_request) Failing after 38s
Supply Chain Audit / Scan PR for supply chain risks (pull_request) Successful in 40s
Tests / e2e (pull_request) Successful in 1m36s
Tests / test (pull_request) Failing after 17m13s
Part of #885
2026-04-21 00:28:15 +00:00
ccaa1cb021 feat: circuit breaker for error cascading
Closes #885

2.33x error cascade factor detected. After 3 consecutive errors,
circuit opens and agent must take corrective action.

Recovery pattern: terminal is the safety net (2300 recoveries).
2026-04-21 00:28:14 +00:00
c6f2855745 fix: restore _format_error helper for test compatibility (#916)
Some checks failed
Docker Build and Publish / build-and-push (push) Has been skipped
Nix / nix (ubuntu-latest) (push) Failing after 2s
Tests / e2e (push) Successful in 2m47s
Tests / test (push) Failing after 27m41s
Build Skills Index / build-index (push) Has been skipped
Build Skills Index / deploy-with-index (push) Has been skipped
Nix / nix (macos-latest) (push) Has been cancelled
fix: restore _format_error helper for test compatibility (#916)
2026-04-20 23:56:27 +00:00
9d180f31cc feat: add session templates
Some checks failed
Contributor Attribution Check / check-attribution (pull_request) Failing after 43s
Docker Build and Publish / build-and-push (pull_request) Has been skipped
Supply Chain Audit / Scan PR for supply chain risks (pull_request) Successful in 45s
Tests / test (pull_request) Failing after 45m24s
Tests / e2e (pull_request) Failing after 7m35s
2026-04-20 15:56:26 +00:00
c17f64fa2c test: add syntax validation tests (#913)
Some checks failed
Docker Build and Publish / build-and-push (pull_request) Has been skipped
Contributor Attribution Check / check-attribution (pull_request) Failing after 41s
Supply Chain Audit / Scan PR for supply chain risks (pull_request) Successful in 29s
Tests / e2e (pull_request) Successful in 2m2s
Tests / test (pull_request) Failing after 1h14m43s
2026-04-20 15:47:35 +00:00
bc7ffc2166 feat: Python syntax validation before execute_code (#913) 2026-04-20 15:46:23 +00:00
Alexander Whitestone
c22cdcaa8e fix: add _validate_gateway_config tests and API_SERVER_KEY network binding warning
Some checks failed
Docker Build and Publish / build-and-push (pull_request) Has been skipped
Contributor Attribution Check / check-attribution (pull_request) Failing after 23s
Supply Chain Audit / Scan PR for supply chain risks (pull_request) Successful in 27s
Tests / e2e (pull_request) Successful in 1m51s
Tests / test (pull_request) Failing after 37m0s
Refs #892 - Gateway config debt: missing keys and broken fallbacks

Changes:
- Add `_is_network_accessible()` helper to gateway/config.py (avoids circular
  import with gateway.platforms.base which imports from gateway.config)
- Add API_SERVER_KEY warning in `_validate_gateway_config`: when the API server
  is enabled on a network-accessible address (0.0.0.0, public IP, hostname) but
  no key is configured, log a warning at config-load time so operators see the
  issue before any adapter initialisation runs
- Add `TestValidateGatewayConfig` in tests/gateway/test_config.py covering:
  - idle_minutes <= 0 and None are corrected to 1440 (default)
  - at_hour outside 0-23 is corrected to 4 (default)
  - Boundary hours 0 and 23 are accepted unchanged
  - Empty platform token triggers a warning log
  - Disabled platform with empty token produces no warning
  - API server on 0.0.0.0 without key logs a warning
  - API server on 127.0.0.1 without key is silent (loopback is allowed)
  - API server with a key set logs no warning regardless of bind address

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 02:18:02 -04:00
Alexander Whitestone
ab968e910c feat: tool fixation detection — break repetitive loops (#886)
Some checks failed
Contributor Attribution Check / check-attribution (pull_request) Failing after 37s
Docker Build and Publish / build-and-push (pull_request) Has been skipped
Supply Chain Audit / Scan PR for supply chain risks (pull_request) Successful in 43s
Tests / e2e (pull_request) Successful in 1m57s
Tests / test (pull_request) Failing after 18m57s
Marathon sessions show tool fixation: agent latches onto one tool
and calls it repeatedly. Observed streaks of 8-25 identical calls.

New agent/tool_fixation_detector.py:
- ToolFixationDetector: tracks consecutive tool calls
- record(tool_name): returns nudge prompt when threshold reached
- Default threshold: 5 consecutive calls (configurable via
  TOOL_FIXATION_THRESHOLD env var)
- Nudge prompt explains the fixation and suggests alternatives:
  1. Read error carefully
  2. Try different tool
  3. Ask user for clarification
  4. Check if task is complete
- get_streak_info(): current streak state
- format_report(): human-readable fixation events
- Singleton via get_fixation_detector()

Config:
- TOOL_FIXATION_THRESHOLD (default: 5)
- TOOL_FIXATION_WINDOW (default: 10)

Tests: tests/test_tool_fixation_detector.py (9 tests)

Closes #886
2026-04-17 01:57:37 -04:00
Alexander Whitestone
73984ca72f feat: Add queue health check script
Some checks failed
Docker Build and Publish / build-and-push (pull_request) Has been skipped
Contributor Attribution Check / check-attribution (pull_request) Failing after 29s
Supply Chain Audit / Scan PR for supply chain risks (pull_request) Successful in 31s
Tests / e2e (pull_request) Successful in 2m13s
Tests / test (pull_request) Failing after 28m10s
2026-04-17 01:26:07 -04:00
436c800def fix: add path validation before read_file (#887)
Some checks failed
Contributor Attribution Check / check-attribution (pull_request) Failing after 35s
Docker Build and Publish / build-and-push (pull_request) Has been skipped
Supply Chain Audit / Scan PR for supply chain risks (pull_request) Successful in 38s
Tests / e2e (pull_request) Successful in 1m58s
Tests / test (pull_request) Failing after 42m6s
- Check if file exists before attempting read
- Return clear error with suggestions for similar files
- Suggest using search_files to find correct path
- Eliminates 83.7% of read_file errors (file not found)

Closes #887
2026-04-17 05:24:52 +00:00
cb331da4f1 test: Add credential redaction tests (#839)
Some checks failed
Docker Build and Publish / build-and-push (pull_request) Has been skipped
Contributor Attribution Check / check-attribution (pull_request) Failing after 49s
Tests / e2e (pull_request) Successful in 2m50s
Tests / test (pull_request) Failing after 11m50s
Supply Chain Audit / Scan PR for supply chain risks (pull_request) Successful in 47s
2026-04-17 05:23:48 +00:00
fa892bfcb9 feat: Add credential redaction for tool outputs (#839) 2026-04-17 05:21:25 +00:00
Alexander Whitestone
0b72884750 feat: time-aware model routing for cron jobs (#889)
Some checks failed
Tests / test (pull_request) Failing after 25m4s
Tests / e2e (pull_request) Successful in 3m19s
Contributor Attribution Check / check-attribution (pull_request) Failing after 14s
Docker Build and Publish / build-and-push (pull_request) Has been skipped
Supply Chain Audit / Scan PR for supply chain risks (pull_request) Successful in 14s
Error rate peaks at 18:00 (9.4%) during evening cron batches vs 4.0%
at 09:00 during interactive work. Route cron tasks to stronger models
during off-hours when user is not present to correct errors.

New agent/time_aware_routing.py:
- resolve_time_aware_model(): routes based on hour, error rate, task type
- Interactive sessions: always use base model (user corrects errors)
- Cron during business hours: use base model (low error rate)
- Cron during off-hours with high error rate (>6%): upgrade to strong model
- get_hour_error_rate(): error rates by hour from empirical audit
- is_off_hours(): 18:00-05:59 = off-hours
- RoutingDecision: model, provider, reason, hour, error_rate
- get_routing_report(): 24h forecast of routing decisions

Config via env vars:
- CRON_STRONG_MODEL (default: xiaomi/mimo-v2-pro)
- CRON_CHEAP_MODEL (default: qwen2.5:7b)
- CRON_ERROR_THRESHOLD (default: 6.0%)

Tests: tests/test_time_aware_routing.py (9 tests)

Closes #889
2026-04-17 01:15:09 -04:00
a0ed1e6ff2 test: profile isolation tests
Some checks failed
Contributor Attribution Check / check-attribution (pull_request) Failing after 15s
Supply Chain Audit / Scan PR for supply chain risks (pull_request) Successful in 15s
Docker Build and Publish / build-and-push (pull_request) Has been skipped
Tests / test (pull_request) Failing after 18m33s
Tests / e2e (pull_request) Successful in 1m17s
Part of #891
2026-04-17 05:13:03 +00:00
b5ba272efe feat: profile session isolation
Closes #891

Tags sessions with originating profile and provides filtered
access so profiles cannot see each other's data.
2026-04-17 05:13:01 +00:00
2e0dfe27df feat: time-aware model routing for cron jobs #889
Some checks failed
Contributor Attribution Check / check-attribution (pull_request) Failing after 15s
Docker Build and Publish / build-and-push (pull_request) Has been skipped
Supply Chain Audit / Scan PR for supply chain risks (pull_request) Successful in 14s
Tests / test (pull_request) Failing after 18m19s
Tests / e2e (pull_request) Successful in 1m17s
2026-04-17 05:10:34 +00:00
d4cdfdc604 test: Add context budget tracker tests (#838)
Some checks failed
Supply Chain Audit / Scan PR for supply chain risks (pull_request) Successful in 19s
Contributor Attribution Check / check-attribution (pull_request) Failing after 16s
Docker Build and Publish / build-and-push (pull_request) Has been skipped
Tests / test (pull_request) Failing after 18m30s
Tests / e2e (pull_request) Successful in 1m16s
2026-04-17 05:06:54 +00:00
e3436e36c3 feat: Add context budget tracker for overflow prevention (#838) 2026-04-17 05:06:08 +00:00
34e7de6a4c feat: 988 Lifeline tests (#673)
Some checks failed
Supply Chain Audit / Scan PR for supply chain risks (pull_request) Successful in 18s
Contributor Attribution Check / check-attribution (pull_request) Failing after 17s
Docker Build and Publish / build-and-push (pull_request) Has been skipped
Tests / test (pull_request) Failing after 18m18s
Tests / e2e (pull_request) Successful in 1m13s
2026-04-17 05:04:50 +00:00
dbabe0e6ae feat: 988 Suicide & Crisis Lifeline integration (#673)
agent/crisis_resources.py provides all 988 Lifeline contact
methods: phone (988), text (HOME to 988), chat, Spanish line.
Also Crisis Text Line (741741) and 911.

Closes #673
2026-04-17 05:04:48 +00:00
517e2c571e fix(#892): Gateway config validation and fallback fixes
Some checks failed
Supply Chain Audit / Scan PR for supply chain risks (pull_request) Successful in 16s
Tests / test (pull_request) Failing after 18m29s
Tests / e2e (pull_request) Successful in 1m20s
Contributor Attribution Check / check-attribution (pull_request) Failing after 16s
Docker Build and Publish / build-and-push (pull_request) Has been skipped
Config validator and fallback fixes:
- Validate required keys (OPENROUTER_API_KEY, API_SERVER_KEY)
- Fix idle_minutes validation (>0 required)
- Fix Discord skill limit (reduce to 95 max)
- Validate provider configs
- Apply sensible defaults

Resolves #892
2026-04-17 05:04:11 +00:00
0b019327a3 docs: cron audit documentation
Some checks failed
Tests / e2e (pull_request) Successful in 49s
Docker Build and Publish / build-and-push (pull_request) Has been skipped
Supply Chain Audit / Scan PR for supply chain risks (pull_request) Successful in 15s
Contributor Attribution Check / check-attribution (pull_request) Failing after 14s
Tests / test (pull_request) Failing after 18m24s
Part of #890
2026-04-17 05:00:09 +00:00
6b0fca6944 feat: cron job audit and cleanup script
Closes #890

Finds dead cron jobs (zero completions, stale) and provides
--disable and --delete actions to clean them up.
2026-04-17 05:00:06 +00:00
05f8c2d188 Merge PR #899
Merged PR #899: feat: Allegro worker deliverables
2026-04-17 01:52:11 +00:00
ff2ce95ade feat(research): Allegro worker deliverables — fleet research reports + skill manager test
Some checks failed
Tests / e2e (pull_request) Successful in 1m39s
Tests / test (pull_request) Failing after 1h7m45s
Docker Build and Publish / build-and-push (pull_request) Has been skipped
Contributor Attribution Check / check-attribution (pull_request) Successful in 24s
Supply Chain Audit / Scan PR for supply chain risks (pull_request) Successful in 28s
Research reports:
- Vector DB research
- Workflow orchestration research
- Fleet knowledge graph SOTA research
- LLM inference optimization
- Local model crisis quality
- Memory systems SOTA
- Multi-agent coordination
- R5 vs E2E gap analysis
- Text-to-music-video

Test:
- test_skill_manager_error_context.py

[Allegro] Forge workers — 2026-04-16
2026-04-16 15:04:28 +00:00
Hermes Merge Bot
aedebfdf58 Merge PR #848 2026-04-16 02:12:13 -04:00
Hermes Merge Bot
adf49b1809 Merge PR #849 2026-04-16 02:11:21 -04:00
Hermes Merge Bot
52ea3a8935 Merge PR #850 2026-04-16 02:09:00 -04:00
Hermes Merge Bot
43246d6cb4 Merge PR #852 2026-04-16 02:08:06 -04:00
Hermes Merge Bot
20c5e237a7 Merge PR #861 2026-04-16 02:06:36 -04:00
Hermes Merge Bot
a0f4d10a7f Merge PR #855 2026-04-16 02:06:17 -04:00
Hermes Merge Bot
bc5d1cf6ff Merge PR #863 2026-04-16 02:05:44 -04:00
Hermes Merge Bot
dff451081d Merge PR #856 2026-04-16 02:05:42 -04:00
Hermes Merge Bot
5509b157c5 Merge PR #864 2026-04-16 02:05:05 -04:00
Hermes Merge Bot
fcc322fb81 Merge PR #867 2026-04-16 02:03:23 -04:00
Hermes Merge Bot
9bba9ecc40 Merge PR #866 2026-04-16 02:02:43 -04:00
Hermes Merge Bot
05086e58ea Merge PR #871 2026-04-16 02:00:55 -04:00
Hermes Merge Bot
7af6889767 Merge PR #869 2026-04-16 02:00:49 -04:00
5022db9d7b Merge pull request 'feat: self-modifying agent that improves its own prompts (#813)' (#897) from fix/813 into main 2026-04-16 05:29:11 +00:00
0f61474b74 Merge pull request 'feat: MCP server — expose hermes tools to fleet peers (#803)' (#896) from fix/803 into main
Auto-merged PR #896: feat: MCP server — expose hermes tools to fleet peers (#803)
2026-04-16 05:24:27 +00:00
Alexander Whitestone
a528bd5b1b fix: use .get() for env_vars key in _show_tool_availability_warnings
Some checks failed
Docker Build and Publish / build-and-push (pull_request) Has been skipped
Supply Chain Audit / Scan PR for supply chain risks (pull_request) Successful in 24s
Tests / test (pull_request) Failing after 1h2m1s
Tests / e2e (pull_request) Successful in 1m38s
Fixes KeyError: 'missing_vars' crash on CLI startup when toolsets are
unavailable. registry.py returns dicts with 'env_vars' key, but
_show_tool_availability_warnings() was accessing 'missing_vars' directly.

Now uses .get("env_vars") or .get("missing_vars") to handle both key
names, consistent with how doctor.py already handles this.

Fixes #834

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 01:23:48 -04:00
Alexander Whitestone
e63cdaf16f feat: self-modifying agent that improves its own prompts (#813)
Some checks failed
Docker Build and Publish / build-and-push (pull_request) Has been cancelled
Contributor Attribution Check / check-attribution (pull_request) Has been cancelled
Supply Chain Audit / Scan PR for supply chain risks (pull_request) Has been cancelled
Tests / test (pull_request) Has been cancelled
Tests / e2e (pull_request) Has been cancelled
Resolves #813. Agent analyzes session transcripts for failure
patterns and generates prompt patches to prevent future failures.

agent/self_modify.py (PromptLearner class):
- analyze_session(): detects 5 failure types from transcripts:
  retry_loop, timeout, hallucination, context_loss, tool_failure
- generate_patches(): converts patterns to prompt patches with
  confidence scoring (frequency-based)
- apply_patches(): appends learned rules to system prompt with
  backup and rollback support
- learn_from_session(): full cycle analyze → patch → apply

Failures → patterns → patches → improved prompts → fewer failures.

Safety: patches only ADD rules (append-only), never remove.
Rollback:  restores from timestamped backup.
2026-04-16 01:23:48 -04:00
Alexander Whitestone
2b7b12baf9 feat: MCP server — expose hermes tools to fleet peers (#803)
Some checks failed
Contributor Attribution Check / check-attribution (pull_request) Successful in 44s
Docker Build and Publish / build-and-push (pull_request) Has been skipped
Tests / test (pull_request) Has been cancelled
Tests / e2e (pull_request) Has been cancelled
Supply Chain Audit / Scan PR for supply chain risks (pull_request) Successful in 19m48s
Resolves #803. Standalone MCP server that exposes safe hermes
tools to other fleet agents.

scripts/mcp_server.py:
- Exposes: terminal, file_read, file_search, web_search, session_search
- Blocks: approval, delegate, memory, config, cron, send_message
- Terminal uses approval.py dangerous command detection
- Auth via Bearer token (MCP_AUTH_KEY)
- HTTP endpoints: GET /mcp/tools, POST /mcp/tools/call, GET /health

Usage:
  python scripts/mcp_server.py --port 8081 --auth-key SECRET
  curl http://localhost:8081/mcp/tools
  curl -X POST http://localhost:8081/mcp/tools/call -d {"name":"file_read","arguments":{"path":"README.md"}}
2026-04-16 01:10:00 -04:00
Alexander Whitestone
6b40c5db7a fix: use env_vars key in _show_tool_availability_warnings to prevent KeyError
registry.py:check_tool_availability() returns unavailable dicts with key
"env_vars", but _show_tool_availability_warnings() in cli.py was accessing
u["missing_vars"] causing a KeyError crashing CLI startup whenever any
toolset was disabled.

Fix matches how doctor.py already handles the same data.

Fixes #834
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 00:42:03 -04:00
5a24894f78 fix: update hermes_cli/web_server.py for agent card discovery
Some checks failed
Contributor Attribution Check / check-attribution (pull_request) Successful in 43s
Docker Build and Publish / build-and-push (pull_request) Has been skipped
Nix / nix (ubuntu-latest) (pull_request) Failing after 5s
Supply Chain Audit / Scan PR for supply chain risks (pull_request) Successful in 38s
Tests / test (pull_request) Failing after 10m58s
Tests / e2e (pull_request) Successful in 1m32s
Nix / nix (macos-latest) (pull_request) Has been cancelled
2026-04-16 03:45:04 +00:00
a474eb8459 fix: add agent/agent_card.py for agent card discovery 2026-04-16 03:45:01 +00:00
Alexander Whitestone
3238cf4eb1 feat: Tool investigation report + Mem0 local provider (#842)
Some checks failed
Contributor Attribution Check / check-attribution (pull_request) Successful in 38s
Docker Build and Publish / build-and-push (pull_request) Has been skipped
Supply Chain Audit / Scan PR for supply chain risks (pull_request) Successful in 32s
Tests / test (pull_request) Failing after 43m54s
Tests / e2e (pull_request) Successful in 2m5s
## Investigation Report
- docs/tool-investigation-2026-04-15.md: Full report analyzing 414 tools
  from awesome-ai-tools. Top 5 recommendations with integration paths.
- docs/plans/awesome-ai-tools-integration.md: Implementation tracking plan.

## Mem0 Local Provider (P1)
- plugins/memory/mem0_local/: New ChromaDB-backed memory provider.
  No API key required - fully sovereign. Compatible tool schemas with
  cloud Mem0 (mem0_profile, mem0_search, mem0_conclude).
- Pattern-based fact extraction from conversations.
- Deterministic dedup via content hashing.
- Circuit breaker for resilience.
- tests/plugins/memory/test_mem0_local.py: Full test coverage.

## Issues Filed
- #857: LightRAG integration (P2)
- #858: n8n workflow orchestration (P3)
- #859: RAGFlow document understanding (P4)
- #860: tensorzero LLMOps evaluation (P3)

Closes #842
2026-04-15 23:04:41 -04:00
eed87e454e test: Benchmark Gemma 4 vision accuracy vs current approach (#817)
Some checks failed
Contributor Attribution Check / check-attribution (pull_request) Successful in 26s
Docker Build and Publish / build-and-push (pull_request) Has been skipped
Supply Chain Audit / Scan PR for supply chain risks (pull_request) Successful in 26s
Tests / e2e (pull_request) Successful in 2m38s
Tests / test (pull_request) Failing after 47m49s
Vision benchmark suite comparing Gemma 4 (google/gemma-4-27b-it) vs
current Gemini 3 Flash Preview (google/gemini-3-flash-preview).

Metrics:
- OCR accuracy (character + word overlap)
- Description completeness (keyword coverage)
- Structural quality (length, sentences, numbers)
- Latency (ms per image)
- Token usage
- Consistency across runs

Features:
- 24 diverse test images (screenshots, diagrams, photos, charts)
- Category-specific evaluation prompts
- Automated verdict with composite scoring
- JSON + markdown report output
- 28 unit tests passing

Usage:
  python benchmarks/vision_benchmark.py --images benchmarks/test_images.json
  python benchmarks/vision_benchmark.py --url https://example.com/img.png
  python benchmarks/vision_benchmark.py --generate-dataset

Closes #817.
2026-04-15 23:02:02 -04:00
Alexander Whitestone
f03709aa29 test: crisis hook integration tests with agent loop (#707)
Some checks failed
Contributor Attribution Check / check-attribution (pull_request) Successful in 16s
Docker Build and Publish / build-and-push (pull_request) Has been skipped
Supply Chain Audit / Scan PR for supply chain risks (pull_request) Successful in 15s
Tests / e2e (pull_request) Failing after 12m38s
Tests / test (pull_request) Failing after 25m58s
10 integration tests verifying crisis detection works correctly
when called from the agent conversation flow:

- scan_user_message detects CRITICAL/HIGH/MEDIUM/LOW levels
- Safe messages pass through without triggering
- Tool handler returns valid JSON
- Compassion injection includes 988 lifeline for CRITICAL/HIGH
- Case insensitive detection
- Empty/None text handled gracefully
- False positive resistance on common non-crisis phrases
- Config check returns bool
- Callable from agent context (not just isolation tests)
2026-04-15 23:00:12 -04:00
Alexander Whitestone
4d8e004b5f fix: extend JSON repair to remaining json.loads sites in run_agent.py
Some checks failed
Contributor Attribution Check / check-attribution (pull_request) Successful in 42s
Docker Build and Publish / build-and-push (pull_request) Has been skipped
Nix / nix (ubuntu-latest) (pull_request) Failing after 4s
Supply Chain Audit / Scan PR for supply chain risks (pull_request) Successful in 36s
Tests / test (pull_request) Failing after 1h13m6s
Tests / e2e (pull_request) Successful in 1m32s
Nix / nix (macos-latest) (pull_request) Has been cancelled
Adds `repair_and_load_json()` to utils.py using the `json_repair` library
as a fallback when `json.loads()` fails. Replaces 8 non-hot-path json.loads
sites identified in issue #809:

- L2250: trajectory/sanitization message content parsing
- L2500: tool_call dict reconstruction in trajectory conversion
- L2535: tool_content parsing (JSON-like strings in tool responses)
- L2888: session log file loading (with warning on unrecoverable parse)
- L3119: todo content parsing in message processing
- L5963: vision result_json parsing
- L6761: memory flush tool call argument parsing
- L8300: cache serialization tool call args normalization

Each site uses an appropriate default ({} for tool args, None/continue for
content parsing) and a context label for debug tracing.

Fixes #809
2026-04-15 22:56:39 -04:00
85a654348a feat: poka-yoke — prevent hardcoded ~/.hermes paths (closes #835)
Some checks failed
Contributor Attribution Check / check-attribution (pull_request) Successful in 27s
Docker Build and Publish / build-and-push (pull_request) Has been skipped
Supply Chain Audit / Scan PR for supply chain risks (pull_request) Successful in 19s
Tests / e2e (pull_request) Successful in 1m55s
Tests / test (pull_request) Failing after 56m41s
scripts/lint_hardcoded_paths.py (new):
- Scans Python files for hardcoded home-directory paths
- Detects: Path.home()/.hermes without env fallback, /Users/<name>/, /home/<name>/
- Excludes: comments, docstrings, test files, skills, plugins, docs
- Excludes correct patterns: profiles_parent, current_default, native_home
- Supports --staged (git pre-commit), --fix (suggestions), --json output

scripts/pre-commit-hardcoded-paths.sh (new):
- Pre-commit hook that runs lint_hardcoded_paths.py --staged
- Blocks commits containing hardcoded path violations

tools/confirmation_daemon.py (fixed):
- Replaced Path.home() / '.hermes' / 'approval_whitelist.json'
  with get_hermes_home() / 'approval_whitelist.json'
- Added import of get_hermes_home from hermes_constants

tests/test_hardcoded_paths.py (new):
- 11 tests: detection, exclusion, fallback patterns, clean files
2026-04-15 22:56:32 -04:00
fc0d8fe5e9 fix: extend JSON repair to ALL remaining json.loads sites (#809)
Some checks failed
Docker Build and Publish / build-and-push (pull_request) Has been skipped
Nix / nix (ubuntu-latest) (pull_request) Failing after 2s
Contributor Attribution Check / check-attribution (pull_request) Successful in 26s
Supply Chain Audit / Scan PR for supply chain risks (pull_request) Successful in 26s
Tests / e2e (pull_request) Successful in 2m50s
Tests / test (pull_request) Failing after 1h17m49s
Nix / nix (macos-latest) (pull_request) Has been cancelled
2026-04-16 02:53:41 +00:00
Alexander Whitestone
13ef670c05 feat: session compaction with fact extraction (#748)
Some checks failed
Contributor Attribution Check / check-attribution (pull_request) Successful in 29s
Docker Build and Publish / build-and-push (pull_request) Has been skipped
Supply Chain Audit / Scan PR for supply chain risks (pull_request) Successful in 33s
Tests / e2e (pull_request) Successful in 3m26s
Tests / test (pull_request) Failing after 1h28m50s
Before compressing conversation context, extract durable facts
(user preferences, corrections, project details) and save to
fact store so they survive compression.

New agent/session_compactor.py:
- extract_facts_from_messages(): scans user messages for
  preferences, corrections, project/infra facts using regex
- 3 pattern categories: user_pref (5 patterns), correction
  (3 patterns), project (4 patterns)
- ExtractedFact: category, entity, content, confidence, source_turn
- save_facts_to_store(): saves to fact store (callback or auto-detect)
- extract_and_save_facts(): one-call extraction + persistence
- Deduplication by category+content
- Skips tool results, short messages, system messages
- format_facts_summary(): human-readable summary

Tests: tests/test_session_compactor.py (9 tests)

Closes #748
2026-04-15 22:41:54 -04:00
4752a0085e fix: extend JSON repair to remaining json.loads sites in run_agent.py (#809) 2026-04-16 02:40:51 +00:00
b26a6ec23b feat: add repair_and_load_json() to utils.py (#809) 2026-04-16 02:38:01 +00:00
Alexander Whitestone
9f0c410481 feat: batch tool execution with parallel safety checks (#749)
Some checks failed
Docker Build and Publish / build-and-push (pull_request) Has been skipped
Contributor Attribution Check / check-attribution (pull_request) Successful in 35s
Supply Chain Audit / Scan PR for supply chain risks (pull_request) Successful in 37s
Tests / e2e (pull_request) Successful in 1m48s
Tests / test (pull_request) Failing after 36m13s
Centralized safety classification for tool call batches:

tools/batch_executor.py (new):
- classify_tool_calls() — classifies batch into parallel_safe,
  path_scoped, sequential, never_parallel tiers
- BatchExecutionPlan — structured plan with parallel and sequential batches
- Path conflict detection — write_file + patch on same file go sequential
- Destructive command detection — rm, mv, sed -i, redirects
- execute_parallel_batch() — ThreadPoolExecutor for concurrent execution

tools/registry.py (enhanced):
- ToolEntry.parallel_safe field — tools can declare parallel safety
- registry.register() accepts parallel_safe=True parameter
- registry.get_parallel_safe_tools() — query registry-declared safe tools

Safety tiers:
- parallel_safe: read_file, web_search, search_files, etc.
- path_scoped: write_file, patch (concurrent when paths don't overlap)
- sequential: terminal, delegate_task, unknown tools
- never_parallel: clarify (requires user interaction)

19 tests passing.
2026-04-15 22:17:16 -04:00
b34b5b293d test: add tests for tool hallucination prevention (#836)
Some checks failed
Contributor Attribution Check / check-attribution (pull_request) Successful in 24s
Docker Build and Publish / build-and-push (pull_request) Has been skipped
Supply Chain Audit / Scan PR for supply chain risks (pull_request) Successful in 22s
Tests / e2e (pull_request) Successful in 3m6s
Tests / test (pull_request) Failing after 41m24s
2026-04-16 02:15:59 +00:00
05f9d2b009 feat: integrate poka-yoke validation into tool dispatch (#836)
- Added import for tool_pokayoke module
- Added validation before orchestrator.dispatch calls
- Auto-corrects tool names and parameters
- Returns structured errors with suggestions
- Circuit breaker for consecutive failures

Closes #836
2026-04-16 02:15:17 +00:00
Timmy Time
fb7464995c fix: Ultraplan Mode for daily autonomous planning (closes #840)
Some checks failed
Contributor Attribution Check / check-attribution (pull_request) Successful in 37s
Docker Build and Publish / build-and-push (pull_request) Has been skipped
Supply Chain Audit / Scan PR for supply chain risks (pull_request) Successful in 39s
Tests / test (pull_request) Failing after 1h15m33s
Tests / e2e (pull_request) Successful in 2m20s
2026-04-15 22:14:16 -04:00
7c71b7e73a test: parallel tool calling — 2+ tools per response (#798)
Some checks failed
Contributor Attribution Check / check-attribution (pull_request) Successful in 45s
Docker Build and Publish / build-and-push (pull_request) Has been skipped
Supply Chain Audit / Scan PR for supply chain risks (pull_request) Successful in 1m16s
Tests / e2e (pull_request) Successful in 3m17s
Tests / test (pull_request) Failing after 1h30m54s
2026-04-16 02:13:00 +00:00
4a3068b3b5 test: add regression tests for issue #834 KeyError fix
Some checks failed
Contributor Attribution Check / check-attribution (pull_request) Successful in 39s
Docker Build and Publish / build-and-push (pull_request) Has been skipped
Supply Chain Audit / Scan PR for supply chain risks (pull_request) Successful in 44s
Tests / e2e (pull_request) Successful in 2m53s
Tests / test (pull_request) Failing after 1h28m32s
2026-04-16 02:12:36 +00:00
a8300ceb43 fix: KeyError 'missing_vars' in _show_tool_availability_warnings (#834) 2026-04-16 02:11:08 +00:00
8ef766beac feat: add tool hallucination prevention module (#836)
- Validates tool names against registered tools
- Auto-corrects parameter names within Levenshtein distance 1
- Circuit breaker for consecutive failures (threshold: 3)
- Structured error messages with suggestions

Closes #836
2026-04-16 02:10:39 +00:00
db72e908f7 Merge pull request 'feat(security): implement Vitalik's secure LLM patterns — privacy filter + confirmation daemon [resolves merge conflict]' (#830) from feat/vitalik-secure-llm-1776303263 into main
Vitalik's secure LLM patterns — privacy filter + confirmation daemon

Clean rebase of #397 onto current main. Resolves merge conflicts in tools/approval.py.
2026-04-16 01:36:58 +00:00
b82b760d5d feat: add Vitalik's threat model patterns to DANGEROUS_PATTERNS
Some checks failed
Docker Build and Publish / build-and-push (pull_request) Has been skipped
Supply Chain Audit / Scan PR for supply chain risks (pull_request) Successful in 41s
Contributor Attribution Check / check-attribution (pull_request) Successful in 51s
Tests / e2e (pull_request) Successful in 5m21s
Tests / test (pull_request) Failing after 45m7s
2026-04-16 01:35:49 +00:00
d8d7846897 feat: add tests/tools/test_confirmation_daemon.py from PR #397 2026-04-16 01:35:24 +00:00
6840d05554 feat: add tests/agent/test_privacy_filter.py from PR #397 2026-04-16 01:35:21 +00:00
8abe59ed95 feat: add tools/confirmation_daemon.py from PR #397 2026-04-16 01:35:18 +00:00
435d790201 feat: add agent/privacy_filter.py from PR #397 2026-04-16 01:35:14 +00:00
Alexander Whitestone
30afd529ac feat: add crisis detection tool — the-door integration (#141)
Some checks failed
Docker Build and Publish / build-and-push (pull_request) Has been skipped
Contributor Attribution Check / check-attribution (pull_request) Successful in 44s
Supply Chain Audit / Scan PR for supply chain risks (pull_request) Successful in 59s
Tests / e2e (pull_request) Successful in 3m49s
Tests / test (pull_request) Failing after 44m1s
New tool: tools/crisis_tool.py
- Wraps the-door's canonical crisis detection (detect.py)
- Scans user messages for despair/suicidal ideation
- Classifies into NONE/LOW/MEDIUM/HIGH/CRITICAL tiers
- Provides recommended actions per tier
- Gateway hook: scan_user_message() for pre-API-call detection
- System prompt injection: compassion_injection based on crisis level
- Optional escalation logging to crisis_escalations.jsonl
- Optional bridge API POST for HIGH+ (configurable via CRISIS_BRIDGE_URL)
- Configurable via crisis_detection: true/false in config.yaml
- Follows the-door design principles: never computes life value,
  never suggests death, errs on side of higher risk

Also: tests/test_crisis_tool.py (9 tests, all passing)
2026-04-15 21:00:06 -04:00
Alexander Whitestone
a244b157be bench: add Gemma 4 vs mimo-v2-pro tool calling benchmark (#796)
Some checks failed
Contributor Attribution Check / check-attribution (pull_request) Successful in 42s
Docker Build and Publish / build-and-push (pull_request) Has been skipped
Supply Chain Audit / Scan PR for supply chain risks (pull_request) Successful in 32s
Tests / e2e (pull_request) Successful in 2m26s
Tests / test (pull_request) Failing after 44m7s
100-call regression test across 7 tool categories:
- File operations (20): read_file, write_file, search_files
- Terminal commands (20): shell execution
- Web search (15): web_search
- Code execution (15): execute_code
- Browser automation (10): browser_navigate
- Delegation (10): delegate_task
- MCP tools (10): mcp_list/read/call

Metrics tracked:
- Schema parse success (valid JSON tool calls)
- Tool name accuracy (correct tool selected)
- Arguments accuracy (required args present)
- Average latency per call

Usage:
  python3 benchmarks/tool_call_benchmark.py --model nous:xiaomi/mimo-v2-pro
  python3 benchmarks/tool_call_benchmark.py --model ollama/gemma4:latest
  python3 benchmarks/tool_call_benchmark.py --compare
2026-04-15 18:56:35 -04:00
d86359cbb2 Merge pull request 'feat: robust tool orchestration and circuit breaking' (#811) from feat/robust-tool-orchestration-1776268138150 into main 2026-04-15 16:03:07 +00:00
f264b55b29 refactor: use ToolOrchestrator for robust tool execution
Some checks failed
Contributor Attribution Check / check-attribution (pull_request) Successful in 36s
Docker Build and Publish / build-and-push (pull_request) Has been skipped
Supply Chain Audit / Scan PR for supply chain risks (pull_request) Successful in 38s
Tests / e2e (pull_request) Successful in 2m37s
Tests / test (pull_request) Failing after 40m19s
2026-04-15 15:49:02 +00:00
dfe23f66b1 feat: add ToolOrchestrator with circuit breaker 2026-04-15 15:49:00 +00:00
Teknium
722331a57d fix: replace hardcoded ~/.hermes with display_hermes_home() in agent-facing text (#10285)
Tool schema descriptions and tool return values contained hardcoded
~/.hermes paths that the model sees and uses. When HERMES_HOME is set
to a custom path (Docker containers, profiles), the agent would still
reference ~/.hermes — looking at the wrong directory.

Fixes 6 locations across 5 files:
- tools/tts_tool.py: output_path schema description
- tools/cronjob_tools.py: script path schema description
- tools/skill_manager_tool.py: skill_manage schema description
- tools/skills_tool.py: two tool return messages
- agent/skill_commands.py: skill config injection text

All now use display_hermes_home() which resolves to the actual
HERMES_HOME path (e.g. /opt/data for Docker, ~/.hermes/profiles/X
for profiles, ~/.hermes for default).

Reported by: Sandeep Narahari (PrithviDevs)
2026-04-15 04:57:55 -07:00
sprmn24
41e2d61b3f feat(discord): add native send_animation for inline GIF playback 2026-04-15 04:51:27 -07:00
Teknium
4da598b48a docs: clarify hermes model vs /model — two commands, two purposes (#10276)
Users are confused about the difference between `hermes model` (terminal
command for full provider setup) and `/model` (session command for switching
between already-configured providers). This distinction was not documented
anywhere.

Changes across 4 doc pages:
- cli-commands.md: Added warning callout explaining the difference, added
  --global flag docs, added 'only see OpenRouter models?' info box
- slash-commands.md: Added notes on both TUI and messaging /model entries
  that /model only switches between configured providers
- providers.md: Added 'Two Commands for Model Management' comparison table
  near top of page, added warning callout in switching section
- faq.md: Added new FAQ entry '/model only shows one provider' with quick
  reference table

Prompted by user feedback in Discord — new users consistently hit this
confusion when trying to add providers from inside a session.
2026-04-15 04:39:34 -07:00
asheriif
33ae403890 fix(gateway): fix matrix lingering typing indicator 2026-04-15 04:16:16 -07:00
Teknium
47e6ea84bb fix: file handle bug, warning text, and tests for Discord media send
- Fix file handle closed before POST: nest session.post() inside
  the 'with open()' block so aiohttp can read the file during upload
- Update warning text to include weixin (also supports media delivery)
- Add 8 unit tests covering: text+media, media-only, missing files,
  upload failures, multiple files, and _send_to_platform routing
2026-04-15 04:16:06 -07:00
sprmn24
4bcb2f2d26 feat(send_message): add native media attachment support for Discord
Previously send_message only supported media delivery for Telegram.
Discord users received a warning that media was omitted.

- Add media_files parameter to _send_discord()
- Upload media via Discord multipart/form-data API (files[0] field)
- Handle Discord in _send_to_platform() same way as Telegram block
- Remove Discord from generic chunk loop (now handled above)
- Update error/warning strings to mention telegram and discord
2026-04-15 04:16:06 -07:00
Teknium
1c4d3216d3 fix(cron): include job_id in delivery and guide models on removal workflow (#10242)
* fix(gateway): suppress duplicate replies on interrupt and streaming flood control

Three fixes for the duplicate reply bug affecting all gateway platforms:

1. base.py: Suppress stale response when the session was interrupted by a
   new message that hasn't been consumed yet. Checks both interrupt_event
   and _pending_messages to avoid false positives. (#8221, #2483)

2. run.py (return path): Remove response_previewed guard from already_sent
   check. Stream consumer's already_sent alone is authoritative — if
   content was delivered via streaming, the duplicate send must be
   suppressed regardless of the agent's response_previewed flag. (#8375)

3. run.py (queued-message path): Same fix — already_sent without
   response_previewed now correctly marks the first response as already
   streamed, preventing re-send before processing the queued message.

The response_previewed field is still produced by the agent (run_agent.py)
but is no longer required as a gate for duplicate suppression. The stream
consumer's already_sent flag is the delivery-level truth about what the
user actually saw.

Concepts from PR #8380 (konsisumer). Closes #8375, #8221, #2483.

* fix(cron): include job_id in delivery and guide models on removal workflow

Users reported cron reminders keep firing after asking the agent to stop.
Root cause: the conversational agent didn't know the job_id (not in delivery)
and models don't reliably do the list→remove two-step without guidance.

1. Include job_id in the cron delivery wrapper so users and agents can
   reference it when requesting removal.

2. Replace confusing footer ('The agent cannot see this message') with
   actionable guidance ('To stop or manage this job, send me a new
   message').

3. Add explicit list→remove guidance in the cronjob tool schema so models
   know to list first and never guess job IDs.
2026-04-15 03:46:58 -07:00
Misturi
dedc4600dd fix(skills): handle missing fields in Google Workspace token file gracefully instead of crashing with KeyError 2026-04-15 03:45:09 -07:00
Misturi
8bc9b5a0b4 fix(skills): use is None check for coordinates in find-nearby to avoid dropping valid 0.0 values 2026-04-15 03:45:09 -07:00
Teknium
2546b7acea fix(gateway): suppress duplicate replies on interrupt and streaming flood control
Three fixes for the duplicate reply bug affecting all gateway platforms:

1. base.py: Suppress stale response when the session was interrupted by a
   new message that hasn't been consumed yet. Checks both interrupt_event
   and _pending_messages to avoid false positives. (#8221, #2483)

2. run.py (return path): Remove response_previewed guard from already_sent
   check. Stream consumer's already_sent alone is authoritative — if
   content was delivered via streaming, the duplicate send must be
   suppressed regardless of the agent's response_previewed flag. (#8375)

3. run.py (queued-message path): Same fix — already_sent without
   response_previewed now correctly marks the first response as already
   streamed, preventing re-send before processing the queued message.

The response_previewed field is still produced by the agent (run_agent.py)
but is no longer required as a gate for duplicate suppression. The stream
consumer's already_sent flag is the delivery-level truth about what the
user actually saw.

Concepts from PR #8380 (konsisumer). Closes #8375, #8221, #2483.
2026-04-15 03:42:24 -07:00
Teknium
7b2700c9af fix(browser): use 127.0.0.1 instead of localhost for CDP default (#10231)
/browser connect set BROWSER_CDP_URL to http://localhost:9222, but
Chrome's --remote-debugging-port only binds to 127.0.0.1 (IPv4).
On macOS, 'localhost' can resolve to ::1 (IPv6) first, causing both
_resolve_cdp_override's /json/version fetch and agent-browser's
--cdp connection to fail when Chrome isn't listening on IPv6.

The socket check in the connect handler already used 127.0.0.1
explicitly and succeeded, masking the mismatch.

Use 127.0.0.1 in the default CDP URL to match what Chrome actually
binds to.
2026-04-15 03:29:37 -07:00
Teknium
a4e1842f12 fix: strip reasoning item IDs from Responses API input when store=False (#10217)
With store=False (our default for the Responses API), the API does not
persist response items.  When reasoning items with 'id' fields were
replayed on subsequent turns, the API attempted a server-side lookup
for those IDs and returned 404:

  Item with id 'rs_...' not found. Items are not persisted when store
  is set to false.

The encrypted_content blob is self-contained for reasoning chain
continuity — the id field is unnecessary and triggers the failed lookup.

Fix: strip 'id' from reasoning items in both _chat_messages_to_responses_input
(message conversion) and _preflight_codex_input_items (normalization layer).
The id is still used for local deduplication but never sent to the API.

Reported by @zuogl448 on GPT-5.4.
2026-04-15 03:19:43 -07:00
Teknium
e69526be79 fix(send_message): URL-encode Matrix room IDs and add Matrix to schema examples (#10151)
Matrix room IDs contain ! and : which must be percent-encoded in URI
path segments per the Matrix C-S spec. Without encoding, some
homeservers reject the PUT request.

Also adds 'matrix:!roomid:server.org' and 'matrix:@user:server.org'
to the tool schema examples so models know the correct target format.
2026-04-15 00:10:59 -07:00
Teknium
180b14442f test: add _parse_target_ref Matrix coverage for salvaged PR #6144 2026-04-15 00:08:14 -07:00
bkadish
03446e06bb fix(send_message): accept Matrix room IDs and user MXIDs as explicit targets
`_parse_target_ref` has explicit-reference branches for Telegram, Feishu,
and numeric IDs, but none for Matrix. As a result, callers of
`send_message(target="matrix:!roomid:server")` or
`send_message(target="matrix:@user:server")` fall through to
`(None, None, False)` and the tool errors out with a resolution failure —
even though a raw Matrix room ID or MXID is the most unambiguous possible
target.

Three-line fix: recognize `!…` as a room ID and `@…` as a user MXID when
platform is `matrix`, and return them as explicit targets. Alias-based
targets (`#…`) continue to go through the normal resolve path.
2026-04-15 00:08:14 -07:00
Teknium
df7be3d8ae fix(cli): /model picker shows curated models instead of full catalog (#10146)
The /model picker called provider_model_ids() which fetches the FULL
live API catalog (hundreds of models for Anthropic, Copilot, etc.) and
only fell back to the curated list when the live fetch failed.

This flips the priority: use the curated model list from
list_authenticated_providers() (same lists as `hermes model` and
gateway pickers), falling back to provider_model_ids() only when the
curated list is empty (e.g. user-defined endpoints).
2026-04-15 00:07:50 -07:00
Ubuntu
da8bab77fb fix(cli): restore messaging toolset for gateway platforms 2026-04-14 23:13:35 -07:00
Teknium
9932366f3c feat(doctor): add Command Installation check for hermes bin symlink
hermes doctor now checks whether the ~/.local/bin/hermes symlink exists
and points to the correct venv entry point. With --fix, it creates or
repairs the symlink automatically.

Covers:
- Missing symlink at ~/.local/bin/hermes (or $PREFIX/bin on Termux)
- Symlink pointing to wrong target
- Missing venv entry point (venv/bin/hermes or .venv/bin/hermes)
- PATH warning when ~/.local/bin is not on PATH
- Skipped on Windows (different mechanism)

Addresses user report: 'python -m hermes_cli.main doesn't have an option
to fix the local bin/install'

10 new tests covering all scenarios.
2026-04-14 23:13:11 -07:00
Teknium
029938fbed fix(cli): defensive subparser routing for argparse bpo-9338 (#10113)
On some Python versions, argparse fails to route subcommand tokens when
the parent parser has nargs='?' optional arguments (--continue).  The
symptom: 'hermes model' produces 'unrecognized arguments: model' even
though 'model' is a registered subcommand.

Fix: when argv contains a token matching a known subcommand, set
subparsers.required=True to force deterministic routing.  If that fails
(e.g. 'hermes -c model' where 'model' is consumed as the session name
for --continue), fall back to the default optional-subparsers behaviour.

Adds 13 tests covering all key argument combinations.

Reported via user screenshot showing the exact error on an installed
version with the model subcommand listed in usage but rejected at parse
time.
2026-04-14 23:13:02 -07:00
Teknium
772cfb6c4e fix: stale agent timeout, uv venv detection, empty response after tools, compression model fallback (#9051, #8620, #9400) (#10093)
Four independent fixes:

1. Reset activity timestamp on cached agent reuse (#9051)
   When the gateway reuses a cached AIAgent for a new turn, the
   _last_activity_ts from the previous turn (possibly hours ago)
   carried over. The inactivity timeout handler immediately saw
   the agent as idle for hours and killed it.

   Fix: reset _last_activity_ts, _last_activity_desc, and
   _api_call_count when retrieving an agent from the cache.

2. Detect uv-managed virtual environments (#8620 sub-issue 1)
   The systemd unit generator fell back to sys.executable (uv's
   standalone Python) when running under 'uv run', because
   sys.prefix == sys.base_prefix. The generated ExecStart pointed
   to a Python binary without site-packages.

   Fix: check VIRTUAL_ENV env var before falling back to
   sys.executable. uv sets VIRTUAL_ENV even when sys.prefix
   doesn't reflect the venv.

3. Nudge model to continue after empty post-tool response (#9400)
   Weaker models sometimes return empty after tool calls. The agent
   silently abandoned the remaining work.

   Fix: append assistant('(empty)') + user nudge message and retry
   once. Resets after each successful tool round.

4. Compression model fallback on permanent errors (#8620 sub-issue 4)
   When the default summary model (gemini-3-flash) returns 503
   'model_not_found' on custom proxies, the compressor entered a
   600s cooldown, leaving context growing unbounded.

   Fix: detect permanent model-not-found errors (503, 404,
   'model_not_found', 'no available channel') and fall back to
   using the main model for compression instead of entering
   cooldown. One-time fallback with immediate retry.

Test plan: 40 compressor tests + 97 gateway/CLI tests + 9 venv tests pass
2026-04-14 22:38:17 -07:00
Teknium
5d5d21556e fix: sync client.api_key during UnicodeEncodeError ASCII recovery (#10090)
The existing recovery block sanitized self.api_key and
self._client_kwargs['api_key'] but did not update self.client.api_key.
The OpenAI SDK stores its own copy of api_key and reads it dynamically
via the auth_headers property on every request. Without this fix, the
retry after sanitization would still send the corrupted key in the
Authorization header, causing the same UnicodeEncodeError.

The bug manifests when an API key contains Unicode lookalike characters
(e.g. ʋ U+028B instead of v) from copy-pasting out of PDFs, rich-text
editors, or web pages with decorative fonts. httpx hard-encodes all
HTTP headers as ASCII, so the non-ASCII char in the Authorization
header triggers the error.

Adds TestApiKeyClientSync with two tests verifying:
- All three key locations are synced after sanitization
- Recovery handles client=None (pre-init) without crashing
2026-04-14 22:37:45 -07:00
kshitijk4poor
9855190f23 feat(compressor): smart collapse, dedup, anti-thrashing, template upgrade, hardening
Combined salvage of PRs #9661, #9663, #9674, #9677, #9678 by kshitijk4poor.

- Smart tool output collapse: informative 1-line summaries replace generic placeholder
- Dedup identical tool results via MD5 hash, truncate large tool_call arguments
- Anti-thrashing: skip compression after 2 consecutive <10% savings passes
- Structured action-log summary template with numbered actions and Active State
- Hardening: max_tokens 1.3x cap, multimodal safety, note idempotency, adaptive cooldown

Follow-up fixes applied during salvage:
- web_extract: reads 'urls' (list) not 'url' (original PR bug)
- Multimodal list content guards in dedup and prune passes
- Kept 'Relevant Files' section in template (original PR removed it)

Skipped PRs #9665 (user msg preservation — duplication risk) and #9675 (dead code).
2026-04-14 22:21:25 -07:00
Teknium
50c35dcabe fix: stale agent timeout, uv venv detection, empty response after tools (#9051, #8620, #9400)
Three independent fixes:

1. Reset activity timestamp on cached agent reuse (#9051)
   When the gateway reuses a cached AIAgent for a new turn, the
   _last_activity_ts from the previous turn (possibly hours ago)
   carried over. The inactivity timeout handler immediately saw
   the agent as idle for hours and killed it.

   Fix: reset _last_activity_ts, _last_activity_desc, and
   _api_call_count when retrieving an agent from the cache.

2. Detect uv-managed virtual environments (#8620 sub-issue 1)
   The systemd unit generator fell back to sys.executable (uv's
   standalone Python) when running under 'uv run', because
   sys.prefix == sys.base_prefix (uv doesn't set up traditional
   venv activation). The generated ExecStart pointed to a Python
   binary without site-packages, crashing the service on startup.

   Fix: check VIRTUAL_ENV env var before falling back to
   sys.executable. uv sets VIRTUAL_ENV even when sys.prefix
   doesn't reflect the venv.

3. Nudge model to continue after empty post-tool response (#9400)
   Weaker models (GLM-5, mimo-v2-pro) sometimes return empty
   responses after tool calls instead of continuing to the next
   step. The agent silently abandoned the remaining work with
   '(empty)' or used prior-turn fallback text.

   Fix: when the model returns empty after tool calls AND there's
   no prior-turn content to fall back on, inject a one-time user
   nudge message telling the model to process the tool results and
   continue. The flag resets after each successful tool round so it
   can fire again on later rounds.

Test plan: 97 gateway + CLI tests pass, 9 venv detection tests pass
2026-04-14 22:16:02 -07:00
Teknium
93fe4ead83 fix: warn on invalid context_length format in config.yaml (#10067)
Previously, non-integer context_length values (e.g. '256K') in
config.yaml were silently ignored, causing the agent to fall back
to 128K auto-detection with no user feedback. This was confusing
for users with custom LiteLLM endpoints expecting larger context.

Now prints a clear stderr warning and logs at WARNING level when
model.context_length or custom_providers[].models.<model>.context_length
cannot be parsed as an integer, telling users to use plain integers
(e.g. 256000 instead of '256K').

Reported by community user ChFarhan via Discord.
2026-04-14 22:14:27 -07:00
Teknium
a8b7db35b2 fix: interrupt agent immediately when user messages during active run (#10068)
When a user sends a message while the agent is executing a task on the
gateway, the agent is now interrupted immediately — not silently queued.
Previously, messages were stored in _pending_messages with zero feedback
to the user, potentially leaving them waiting 1+ hours.

Root cause: Level 1 guard (base.py) intercepted all messages for active
sessions and returned with no response. Level 2 (gateway/run.py) which
calls agent.interrupt() was never reached.

Fix: Expand _handle_active_session_busy_message to handle the normal
(non-draining) case:
  1. Call running_agent.interrupt(text) to abort in-flight tool calls
     and exit the agent loop at the next check point
  2. Store the message as pending so it becomes the next turn once the
     interrupted run returns
  3. Send a brief ack: 'Interrupting current task (10 min elapsed,
     iteration 21/60, running: terminal). I'll respond shortly.'
  4. Debounce acks to once per 30s to avoid spam on rapid messages

Reported by @Lonely__MH.
2026-04-14 22:07:28 -07:00
Teknium
8548893d14 feat: entry-level Podman support — find_docker() + rootless entrypoint (#10066)
- find_docker() now checks HERMES_DOCKER_BINARY env var first, then
  docker on PATH, then podman on PATH, then macOS known locations
- Entrypoint respects HERMES_HOME env var (was hardcoded to /opt/data)
- Entrypoint uses groupmod -o to tolerate non-unique GIDs (fixes macOS
  GID 20 conflict with Debian's dialout group)
- Entrypoint makes chown best-effort so rootless Podman continues
  instead of failing with 'Operation not permitted'
- 5 new tests covering env var override, podman fallback, precedence

Based on work by alanjds (PR #3996) and malaiwah (PR #8115).
Closes #4084.
2026-04-14 21:20:37 -07:00
Teknium
c5688e7c8b fix(gateway): break compression-exhaustion infinite loop and auto-reset session (#9893)
When compression fails after max attempts, the agent returns
{completed: False, partial: True} but was missing the 'failed' flag.
The gateway's agent_failed_early guard checked for 'failed' AND
'not final_response', but _run_agent_blocking always converts errors
to final_response — making the guard dead code.  This caused the
oversized session to persist, creating an infinite fail loop where
every subsequent message hits the same compression failure.

Changes:
- run_agent.py: add 'failed: True' and 'compression_exhausted: True'
  to all 5 compression-exhaustion return paths
- gateway/run.py (_run_agent_blocking): forward 'failed' and
  'compression_exhausted' flags through to the caller
- gateway/run.py (_handle_message_with_agent): fix agent_failed_early
  to check bool(failed) without the broken 'not final_response' clause;
  auto-reset the session when compression is exhausted so the next
  message starts fresh
- Update tests to match new guard logic and add
  TestCompressionExhaustedFlag test class

Closes #9893
2026-04-14 21:18:17 -07:00
Teknium
ba24f058ed docs: fix stale docstring reference to _discover_tools in mcp_tool.py 2026-04-14 21:12:29 -07:00
Teknium
ef04de3e98 docs: update tool-adding instructions for auto-discovery
- AGENTS.md: 3 files → 2 files, remove _discover_tools() step
- adding-tools.md: remove Step 3, note auto-discovery
- architecture.md: update discovery description
- tools-runtime.md: replace manual list with discover_builtin_tools() docs
- hermes-agent skill: remove manual import step
2026-04-14 21:12:29 -07:00
Teknium
fc6cb5b970 fix: tighten AST check to module-level only
The original tree-wide ast.walk() would match registry.register() calls
inside functions too. Restrict to top-level ast.Expr statements so helper
modules that call registry.register() inside a function are never picked
up as tool modules.
2026-04-14 21:12:29 -07:00
Greer Guthrie
4b2a1a4337 fix(tools): auto-discover built-in tool modules 2026-04-14 21:12:29 -07:00
Teknium
2871ef1807 docs: note session continuity for previous_response_id chains (#10060) 2026-04-14 21:07:37 -07:00
Teknium
5cbb45d93e fix: preserve session_id across previous_response_id chains in /v1/responses (#10059)
The /v1/responses endpoint generated a new UUID session_id for every
request, even when previous_response_id was provided. This caused each
turn of a multi-turn conversation to appear as a separate session on the
web dashboard, despite the conversation history being correctly chained.

Fix: store session_id alongside the response in the ResponseStore, and
reuse it when a subsequent request chains via previous_response_id.
Applies to both the non-streaming /v1/responses path and the streaming
SSE path. The /v1/runs endpoint also gains session continuity from
stored responses (explicit body.session_id still takes priority).

Adds test verifying session_id is preserved across chained requests.
2026-04-14 21:06:32 -07:00
Teknium
ca0ae56ccb fix: add 402 billing error hint to gateway error handler (#5220) (#10057)
* fix: hermes gateway restart waits for service to come back up (#8260)

Previously, systemd_restart() sent SIGUSR1 to the gateway, printed
'restart requested', and returned immediately. The gateway still
needed to drain active agents, exit with code 75, wait for systemd's
RestartSec=30, and start the new process. The user saw 'success' but
the gateway was actually down for 30-60 seconds.

Now the SIGUSR1 path blocks with progress feedback:

Phase 1 — wait for old process to die:
   User service draining active work...
  Polls os.kill(pid, 0) until ProcessLookupError (up to 90s)

Phase 2 — wait for new process to become active:
   Waiting for hermes-gateway to restart...
  Polls systemctl is-active + verifies new PID (up to 60s)

Success:
  ✓ User service restarted (PID 12345)

Timeout:
  ⚠ User service did not become active within 60s.
    Check status: hermes gateway status
    Check logs: journalctl --user -u hermes-gateway --since '2 min ago'

The reload-or-restart fallback path (line 1189) already blocks because
systemctl reload-or-restart is synchronous.

Test plan:
- Updated test to verify wait-for-restart behavior
- All 118 gateway CLI tests pass

* fix: add 402 billing error hint to gateway error handler (#5220)

The gateway's exception handler for agent errors had specific hints for
HTTP 401, 429, 529, 400, 500 — but not 402 (Payment Required / quota
exhausted). Users hitting billing limits from custom proxy providers
got a generic error with no guidance.

Added: 'Your API balance or quota is exhausted. Check your provider
dashboard.'

The underlying billing classification (error_classifier.py) already
correctly handles 402 as FailoverReason.billing with credential
rotation and fallback. The original issue (#5220) where 402 killed
the entire gateway was from an older version — on current main, 402
is excluded from the is_client_error abort path (line 9460) and goes
through the proper retry/fallback/fail flow. Combined with PR #9875
(auto-recover from unexpected SIGTERM), even edge cases where the
gateway dies are now survivable.
2026-04-14 21:03:05 -07:00
Teknium
23b87c8ca8 chore: add zons-zhaozhy to AUTHOR_MAP 2026-04-14 21:01:40 -07:00
阿泥豆
92385679b6 fix: reset retry counters after compression and stop poisoning conversation history
Three bugfixes in the agent loop:

1. Reset retry counters after context compression. Without this,
   pre-compression retry counts carry over, causing the model to
   hit empty-response recovery immediately after a compression-
   induced context loss, wasting API calls on a now-valid context.

2. Unmute output in the final-response (no-tool-call) branch.
   _mute_post_response could be left True from a prior housekeeping
   turn, silently suppressing empty-response warnings and recovery
   status that the user should see.

3. Stop injecting 'Calling the X tools...' into assistant message
   content when falling back to prior-turn content. This mutated
   conversation history with synthetic text that the model never
   produced, poisoning subsequent turns.
2026-04-14 21:01:40 -07:00
Teknium
82f364ffd1 feat: add --all flag to gateway start and restart commands (#10043)
- gateway start --all: kills all stale gateway processes across all
  profiles before starting the current profile's service
- gateway restart --all: stops all gateway processes across all
  profiles, then starts the current profile's service fresh
- gateway stop --all: already existed, unchanged

The --all flag was only available on 'stop' but not on 'start' or
'restart', causing 'unrecognized arguments' errors for users.
2026-04-14 20:52:18 -07:00
Teknium
31d0620663 chore: add simon-marcus to AUTHOR_MAP 2026-04-14 20:51:52 -07:00
Teknium
cf1d718823 fix: keep batch-path function_call_output.output as string per OpenAI spec
The streaming path emits output as content-part arrays for Open WebUI
compatibility, but the batch (non-streaming) Responses API path must
return output as a plain string per the OpenAI Responses API spec.
Reverts the _extract_output_items change from the cherry-picked commits
while preserving the streaming path's array format.
2026-04-14 20:51:52 -07:00
simon-marcus
302554b158 fix(api-server): format responses tool outputs for open webui 2026-04-14 20:51:52 -07:00
simon-marcus
d6c09ab94a feat(api-server): stream /v1/responses SSE tool events 2026-04-14 20:51:52 -07:00
Teknium
da528a8207 fix: detect and strip non-ASCII characters from API keys (#6843)
API keys containing Unicode lookalike characters (e.g. ʋ U+028B instead
of v) cause UnicodeEncodeError when httpx encodes the Authorization
header as ASCII.  This commonly happens when users copy-paste keys from
PDFs, rich-text editors, or web pages with decorative fonts.

Three layers of defense:

1. **Save-time validation** (hermes_cli/config.py):
   _check_non_ascii_credential() strips non-ASCII from credential values
   when saving to .env, with a clear warning explaining the issue.

2. **Load-time sanitization** (hermes_cli/env_loader.py):
   _sanitize_loaded_credentials() strips non-ASCII from credential env
   vars (those ending in _API_KEY, _TOKEN, _SECRET, _KEY) after dotenv
   loads them, so the rest of the codebase never sees non-ASCII keys.

3. **Runtime recovery** (run_agent.py):
   The UnicodeEncodeError recovery block now also sanitizes self.api_key
   and self._client_kwargs['api_key'], fixing the gap where message/tool
   sanitization succeeded but the API key still caused httpx to fail on
   the Authorization header.

Also: hermes_logging.py RotatingFileHandler now explicitly sets
encoding='utf-8' instead of relying on locale default (defensive
hardening for ASCII-locale systems).
2026-04-14 20:20:31 -07:00
kshitijk4poor
677f1227c3 fix: remove @staticmethod from _context_completions — crashes on @ mention
PR #9467 added a call to self._fuzzy_file_completions() inside
_context_completions(), but the method was still decorated with
@staticmethod and didn't receive self. Every @ mention in the input
triggers 'name self is not defined' from prompt_toolkit's async
completer, spamming the error on every keystroke.

Fix: remove @staticmethod, add self parameter. The method already uses
self._fuzzy_file_completions() and self._get_project_files() via that
call chain, so it was never meant to stay static after the fuzzy search
feature was added.
2026-04-14 19:43:42 -07:00
Teknium
4610551d74 fix: update stale comment referencing removed _sync_mcp_toolsets 2026-04-14 17:19:20 -07:00
Greer Guthrie
498cb7a0fc chore(release): map greer guthrie attribution 2026-04-14 17:19:20 -07:00
Greer Guthrie
c10fea8d26 fix(mcp): make server aliases explicit 2026-04-14 17:19:20 -07:00
Greer Guthrie
cda64a5961 fix(mcp): resolve toolsets from live registry 2026-04-14 17:19:20 -07:00
Teknium
2a98098035 fix: hermes gateway restart waits for service to come back up (#8260)
Previously, systemd_restart() sent SIGUSR1 to the gateway, printed
'restart requested', and returned immediately. The gateway still
needed to drain active agents, exit with code 75, wait for systemd's
RestartSec=30, and start the new process. The user saw 'success' but
the gateway was actually down for 30-60 seconds.

Now the SIGUSR1 path blocks with progress feedback:

Phase 1 — wait for old process to die:
   User service draining active work...
  Polls os.kill(pid, 0) until ProcessLookupError (up to 90s)

Phase 2 — wait for new process to become active:
   Waiting for hermes-gateway to restart...
  Polls systemctl is-active + verifies new PID (up to 60s)

Success:
  ✓ User service restarted (PID 12345)

Timeout:
  ⚠ User service did not become active within 60s.
    Check status: hermes gateway status
    Check logs: journalctl --user -u hermes-gateway --since '2 min ago'

The reload-or-restart fallback path (line 1189) already blocks because
systemctl reload-or-restart is synchronous.

Test plan:
- Updated test to verify wait-for-restart behavior
- All 118 gateway CLI tests pass
2026-04-14 17:12:58 -07:00
Teknium
6c89306437 fix: break stuck session resume loops after repeated restarts (#7536)
When a session gets stuck (hung terminal, runaway tool loop) and the
user restarts the gateway, the same session history loads and puts the
agent right back in the stuck state. The user is trapped in a loop:
restart → stuck → restart → stuck.

Fix: track restart-failure counts per session using a simple JSON file
(.restart_failure_counts). On each shutdown with active agents, the
counter increments for those sessions. On startup, if any session has
been active across 3+ consecutive restarts, it's auto-suspended —
giving the user a clean slate on their next message.

The counter resets to 0 when a session completes a turn successfully
(response delivered), so normal sessions that happen to be active
during planned restarts (/restart, hermes update) won't accumulate
false counts.

Implementation:
- _increment_restart_failure_counts(): called during stop() when
  agents are active. Writes {session_key: count} to JSON file.
  Sessions NOT active are dropped (loop broken).
- _suspend_stuck_loop_sessions(): called on startup. Reads the file,
  suspends sessions at threshold (3), clears the file.
- _clear_restart_failure_count(): called after successful response
  delivery. Removes the session from the counter file.

No SessionEntry schema changes. No database migration. Pure file-based
tracking that naturally cleans up.

Test plan:
- 9 new stuck-loop tests (increment, accumulate, threshold, clear,
  suspend, file cleanup, edge cases)
- All 28 gateway lifecycle tests pass (restart drain + auto-continue
  + stuck loop)
2026-04-14 17:08:35 -07:00
Teknium
847d7cbea5 fix: improve CLI text padding, word-wrap for responses and verbose tool output (#9920)
* feat(skills): add fitness-nutrition skill to optional-skills

Cherry-picked from PR #9177 by @haileymarshall.

Adds a fitness and nutrition skill for gym-goers and health-conscious users:
- Exercise search via wger API (690+ exercises, free, no auth)
- Nutrition lookup via USDA FoodData Central (380K+ foods, DEMO_KEY fallback)
- Offline body composition calculators (BMI, TDEE, 1RM, macros, body fat %)
- Pure stdlib Python, no pip dependencies

Changes from original PR:
- Moved from skills/ to optional-skills/health/ (correct location)
- Fixed BMR formula in FORMULAS.md (removed confusing -5+10, now just +5)
- Fixed author attribution to match PR submitter
- Marked USDA_API_KEY as optional (DEMO_KEY works without signup)

Also adds optional env var support to the skill readiness checker:
- New 'optional: true' field in required_environment_variables entries
- Optional vars are preserved in metadata but don't block skill readiness
- Optional vars skip the CLI capture prompt flow
- Skills with only optional missing vars show as 'available' not 'setup_needed'

* fix: increase CLI response text padding to 4-space tab indent

Increases horizontal padding on all response display paths:

- Rich Panel responses (main, background, /btw): padding (1,2) -> (1,4)
- Streaming text: add 4-space indent prefix to each line
- Streaming TTS: add 4-space indent prefix to sentences

Gives response text proper breathing room with a tab-width indent.
Rich Panel word wrapping automatically adjusts for the wider padding.

Requested by AriesTheCoder.

* fix: word-wrap verbose tool call args and results to terminal width

Verbose mode (tool_progress: verbose) printed tool args and results as
single unwrapped lines that could be thousands of characters long.

Adds _wrap_verbose() helper that:
- Pretty-prints JSON args with indent=2 instead of one-line dumps
- Splits text on existing newlines (preserves JSON/structured output)
- Wraps lines exceeding terminal width with 5-char continuation indent
- Uses break_long_words=True for URLs and paths without spaces

Applied to all 4 verbose print sites:
- Concurrent tool call args
- Concurrent tool results
- Sequential tool call args
- Sequential tool results

---------

Co-authored-by: haileymarshall <haileymarshall@users.noreply.github.com>
2026-04-14 16:58:23 -07:00
Teknium
a9c78d0eb0 feat(setup): add recommendation badges to tool provider selection (#9929)
New users don't know which tool providers to pick during setup.
Add [badge] labels to each provider in the selection menu:

  - [★ recommended · free] for best default choices (Edge TTS, Local Browser)
  - [★ recommended] for top-tier paid options (Firecrawl Cloud)
  - [paid] for options requiring an API key
  - [free tier] for services with a free tier (Tavily)
  - [free · self-hosted] / [free · local] for self-run options
  - [subscription] for Nous subscription-managed options

Also improves vague tag descriptions — e.g. 'AI-native search and
contents' becomes 'Neural search with semantic understanding' and
Tavily gets '1000 free searches/mo'.

Both hermes setup and hermes tools share the same rendering path,
so badges appear in both flows.

Addresses user feedback about setup being confusing for newcomers.
2026-04-14 16:58:10 -07:00
Teknium
e7475b1582 feat: auto-continue interrupted agent work after gateway restart (#4493)
When the gateway restarts mid-agent-work, the session transcript ends
on a tool result the agent never processed. Previously, the user had
to type 'continue' or use /retry (which replays from scratch, losing
all prior work).

Now, when the next user message arrives and the loaded history ends
with role='tool', a system note is prepended:

  [System note: Your previous turn was interrupted before you could
  process the last tool result(s). Please finish processing those
  results and summarize what was accomplished, then address the
  user's new message below.]

This is injected in _run_agent()'s run_sync closure, right before
calling agent.run_conversation(). The agent sees the full history
(including the pending tool results) and the system note, so it can
summarize what was accomplished and then handle the user's new input.

Design decisions:
- No new session flags or schema changes — purely detects trailing
  tool messages in the loaded history
- Works for any restart scenario (clean, crash, SIGTERM, drain timeout)
  as long as the session wasn't suspended (suspended = fresh start)
- The user's actual message is preserved after the note
- If the session WAS suspended (unclean shutdown), the old history is
  abandoned and the user starts fresh — no false auto-continue

Also updates the shutdown notification message from 'Use /retry after
restart to continue' to 'Send any message after restart to resume
where it left off' — which is now accurate.

Test plan:
- 6 new auto-continue tests (trailing tool detection, no false
  positives for assistant/user/empty history, multi-tool, message
  preservation)
- All 13 restart drain tests pass (updated /retry assertion)
2026-04-14 16:56:49 -07:00
Teknium
ac1f8fcccd docs(termux): note browser tool PATH auto-discovery
Update the Termux guide to mention that the browser tool now
automatically discovers Termux directories, and add the missing
pkg install nodejs-lts step.
2026-04-14 16:55:55 -07:00
adybag14-cyber
56c34ac4f7 fix(browser): add termux PATH fallbacks
Refactor browser tool PATH construction to include Termux directories
(/data/data/com.termux/files/usr/bin, /data/data/com.termux/files/usr/sbin)
so agent-browser and npx are discoverable on Android/Termux.

Extracts _browser_candidate_path_dirs() and _merge_browser_path() helpers
to centralize PATH construction shared between _find_agent_browser() and
_run_browser_command(), replacing duplicated inline logic.

Also fixes os.pathsep usage (was hardcoded ':') for cross-platform correctness.

Cherry-picked from PR #9846.
2026-04-14 16:55:55 -07:00
Teknium
3ca7417c2a chore: add areu01or00 to AUTHOR_MAP 2026-04-14 16:55:48 -07:00
areu01or00
cfa24532d3 fix(discord): register native /restart slash command 2026-04-14 16:55:48 -07:00
Teknium
b24e5ee4b0 feat(google-workspace): add --from flag for custom sender display name (#9931)
Adds --from flag to gmail send and gmail reply commands, allowing agents
to customize the From header display name when sharing the same email
account. Usage: --from '"Agent Name" <user@example.com>'

Also syncs repo google_api.py with the deployed standalone implementation
(replaces outdated gws_bridge thin wrapper), adds dedicated docs page
under Features > Skills, and updates sidebar navigation.

Requested by community user @Maxime44.
2026-04-14 16:55:34 -07:00
Julien Talbot
3b50821555 feat(xai): add xAI/Grok to provider prefix stripping
Add 'xai', 'x-ai', 'x.ai', 'grok' to _PROVIDER_PREFIXES so that
colon-prefixed model names (e.g. xai:grok-4.20) are stripped correctly
for context length lookups.

Cherry-picked from PR #9184 by @Julientalbot.
2026-04-14 16:43:42 -07:00
Teknium
10494b42a1 feat(discord): register skills under /skill command group with category subcommands (#9909)
Instead of consuming one top-level slash command slot per skill (hitting the
100-command limit with ~26 built-ins + 74 skills), skills are now organized
under a single /skill group command with category-based subcommand groups:

  /skill creative ascii-art [args]
  /skill media gif-search [args]
  /skill mlops axolotl [args]

Discord supports 25 subcommand groups × 25 subcommands = 625 max skills,
well beyond the previous 74-slot ceiling.

Categories are derived from the skill directory structure:
- skills/creative/ascii-art/ → category 'creative'
- skills/mlops/training/axolotl/ → category 'mlops' (top-level parent)
- skills/dogfood/ → uncategorized (direct subcommand)

Changes:
- hermes_cli/commands.py: add discord_skill_commands_by_category() with
  category grouping, hub/disabled filtering, Discord limit enforcement
- gateway/platforms/discord.py: replace top-level skill registration with
  _register_skill_group() using app_commands.Group hierarchy
- tests: 7 new tests covering group creation, category grouping,
  uncategorized skills, hub exclusion, deep nesting, empty skills,
  and handler dispatch

Inspired by Discord community suggestion from bottium.
2026-04-14 16:27:02 -07:00
Teknium
039023f497 diag: log all hermes processes on unexpected gateway shutdown (#9905)
When the gateway receives SIGTERM/SIGINT, the shutdown handler now
runs 'ps aux' and logs every hermes/gateway-related process (excluding
itself). This will show in agent.log as:

  WARNING: Shutdown diagnostic — other hermes processes running:
    hermes  1234 ... hermes update --gateway
    hermes  5678 ... hermes gateway restart

This is the missing diagnostic for #5646 / #6666 — we can prove
the restarts are from systemctl but can't determine WHO issues the
systemctl command. Next time it happens, the agent.log will contain
the evidence (the process that sent the signal or called systemctl
should still be alive when the handler fires).
2026-04-14 16:26:36 -07:00
Teknium
6448e1da23 feat(zai): add GLM-5V-Turbo support for coding plan (#9907)
- Add glm-5v-turbo to OpenRouter, Nous, and native Z.AI model lists
- Add glm-5v context length entry (200K tokens) to model metadata
- Update Z.AI endpoint probe to try multiple candidate models per
  endpoint (glm-5.1, glm-5v-turbo, glm-4.7) — fixes detection for
  newer coding plan accounts that lack older models
- Add zai to _PROVIDER_VISION_MODELS so auxiliary vision tasks
  (vision_analyze, browser screenshots) route through 5v

Fixes #9888
2026-04-14 16:26:01 -07:00
Teknium
1e5e1e822b fix: ESC cancels secret/sudo prompts, clearer skip messaging (#9902)
- Add ESC key binding (eager) for secret_state and sudo_state modal
  prompts — fires immediately, same behavior as Ctrl+C cancel
- Update placeholder text: 'Enter to submit · ESC to skip' (was
  'Enter to skip' which was confusing — Enter on empty looked like
  submitting nothing rather than intentionally skipping)
- Update widget body text: 'ESC or Ctrl+C to skip'
- Change feedback message from 'Secret entry cancelled' to 'Secret
  entry skipped' — more accurate for the action taken
- getpass fallback prompt also updated for non-TUI mode
2026-04-14 16:11:37 -07:00
Teknium
55ce76b372 feat: add architecture-diagram skill (Cocoon AI port) (#9906)
Port of Cocoon AI's architecture-diagram-generator (MIT) as a Hermes skill.
Generates professional dark-themed system architecture diagrams as standalone
HTML/SVG files. Self-contained output, no dependencies.

- SKILL.md with design system specs, color palette, layout rules
- HTML template with all component types, arrow styles, legend examples
- Fits alongside excalidraw in creative/ category

Source: https://github.com/Cocoon-AI/architecture-diagram-generator
2026-04-14 16:10:18 -07:00
Teknium
1525624904 fix: block agent from self-destructing gateway via terminal (#6666)
Add dangerous command patterns that require approval when the agent
tries to run gateway lifecycle commands via the terminal tool:

- hermes gateway stop/restart — kills all running agents mid-work
- hermes update — pulls code and restarts the gateway
- systemctl restart/stop (with optional flags like --user)

These patterns fire the approval prompt so the user must explicitly
approve before the agent can kill its own gateway process. In YOLO
mode, the commands run without approval (by design — YOLO means the
user accepts all risks).

Also fixes the existing systemctl pattern to handle flags between
the command and action (e.g. 'systemctl --user restart' was previously
undetected because the regex expected the action immediately after
'systemctl').

Root cause: issue #6666 reported agents running 'hermes gateway
restart' via terminal, killing the gateway process mid-agent-loop.
The user sees the agent suddenly stop responding with no explanation.
Combined with the SIGTERM auto-recovery from PR #9875, the gateway
now both prevents accidental self-destruction AND recovers if it
happens anyway.

Test plan:
- Updated test_systemctl_restart_not_flagged → test_systemctl_restart_flagged
- All 119 approval tests pass
- E2E verified: hermes gateway restart, hermes update, systemctl
  --user restart all detected; hermes gateway status, systemctl
  status remain safe
2026-04-14 15:43:31 -07:00
Teknium
353b5bacbd test: add tests for /health/detailed endpoint and gateway health probe
- TestHealthDetailedEndpoint: 3 tests for the new API server endpoint
  (returns runtime data, handles missing status, no auth required)
- TestProbeGatewayHealth: 5 tests for _probe_gateway_health()
  (URL normalization, successful/failed probes, fallback chain)
- TestStatusRemoteGateway: 4 tests for /api/status remote fallback
  (remote probe triggers, skipped when local PID found, null PID handling)
2026-04-14 15:41:30 -07:00
Hermes Agent
139a5e37a4 docs(docker): add dashboard section, expose API port, update Compose example
- Running in gateway mode: expose port 8642 for the API server and
  health endpoint, with a note on when it's needed.
- New 'Running the dashboard' section: docker run command with
  GATEWAY_HEALTH_URL and env var reference table.
- Docker Compose example: updated to include both gateway and dashboard
  services with internal network connectivity (hermes-net), so the
  dashboard probes the gateway via http://hermes:8642.
- Concurrent access warning: clarified that running a read-only
  dashboard alongside the gateway is safe.
2026-04-14 15:41:30 -07:00
Hermes Agent
673acf22ae fix: override stale 'stopped' state when health probe confirms gateway alive
When the gateway responds to the health probe but the local
gateway_state.json has a stale 'stopped' state (common in cross-container
setups where the file was written before the gateway restarted), the
dashboard would show 'Running (remote)' but with a 'Stopped' badge.

Now if the HTTP probe succeeded (remote_health_body is not None) and
gateway_state is 'stopped' or None, override it to 'running'. Also
handles the no-shared-volume case where runtime is None entirely.
2026-04-14 15:41:30 -07:00
Hermes Agent
6ed682f111 fix: normalise GATEWAY_HEALTH_URL to base URL before probing
The probe was appending '/detailed' to whatever URL was provided,
so GATEWAY_HEALTH_URL=http://host:8642 would try /8642/detailed
and /8642 — neither of which are valid routes.

Now strips any trailing /health or /health/detailed from the env var
and always probes {base}/health/detailed then {base}/health.
Accepts bare base URL, /health, or /health/detailed forms.
2026-04-14 15:41:30 -07:00
Hermes Agent
45595f4805 feat(dashboard): add HTTP health probe for cross-container gateway detection
The dashboard's gateway status detection relied solely on local PID checks
(os.kill + /proc), which fails when the gateway runs in a separate container.

Changes:
- web_server.py: Add _probe_gateway_health() that queries the gateway's HTTP
  /health/detailed endpoint when the local PID check fails. Activated by
  setting the GATEWAY_HEALTH_URL env var (e.g. http://gateway:8642/health).
  Falls back to standard PID check when the env var is not set.
- api_server.py: Add GET /health/detailed endpoint that returns full gateway
  state (platforms, gateway_state, active_agents, pid, etc.) without auth.
  The existing GET /health remains unchanged for backwards compatibility.
- StatusPage.tsx: Handle the case where gateway_pid is null but the gateway
  is running remotely, displaying 'Running (remote)' instead of 'PID null'.

Environment variables:
- GATEWAY_HEALTH_URL: URL of the gateway health endpoint (e.g.
  http://gateway-container:8642/health). Unset = local PID check only.
- GATEWAY_HEALTH_TIMEOUT: Probe timeout in seconds (default: 3).
2026-04-14 15:41:30 -07:00
Teknium
397386cae2 fix: gateway auto-recovers from unexpected SIGTERM via systemd (#5646)
Root cause: when the gateway received SIGTERM (from hermes update,
external kill, WSL2 runtime, etc.), it exited with status 0. systemd's
Restart=on-failure only restarts on non-zero exit, so the gateway
stayed dead permanently. Users had to manually restart.

Fix 1: Signal-initiated shutdown exits non-zero
When SIGTERM/SIGINT is received and no restart was requested (via
/restart, /update, or SIGUSR1), start_gateway() returns False which
causes sys.exit(1). systemd sees a failure exit and auto-restarts
after RestartSec=30.

This is safe because systemctl stop tracks its own stop-requested
state independently of exit code — Restart= never fires for a
deliberate stop, regardless of exit code.

Also logs 'Received SIGTERM/SIGINT — initiating shutdown' so the
cause of unexpected shutdowns is visible in agent.log.

Fix 2: PID file ownership guard
remove_pid_file() now checks that the PID file belongs to the current
process before removing it. During --replace handoffs, the old
process's atexit handler could fire AFTER the new process wrote its
PID file, deleting the new record. This left the gateway running but
invisible to get_running_pid(), causing 'Another gateway already
running' errors on next restart.

Test plan:
- All restart drain tests pass (13)
- All gateway service tests pass (84)
- All update gateway restart tests pass (34)
2026-04-14 15:35:58 -07:00
Teknium
eed891f1bb security: supply chain hardening — CI pinning, dep pinning, and code fixes (#9801)
CI/CD Hardening:
- Pin all 12 GitHub Actions to full commit SHAs (was mutable @vN tags)
- Add explicit permissions: {contents: read} to 4 workflows
- Pin CI pip installs to exact versions (pyyaml==6.0.2, httpx==0.28.1)
- Extend supply-chain-audit.yml to scan workflow, Dockerfile, dependency
  manifest, and Actions version changes

Dependency Pinning:
- Pin git-based Python deps to commit SHAs (atroposlib, tinker, yc-bench)
- Pin WhatsApp Baileys from mutable branch to commit SHA

Tool Registry:
- Reject tool name shadowing from different tool families (plugins/MCP
  cannot overwrite built-in tools). MCP-to-MCP overwrites still allowed.

MCP Security:
- Add tool description content scanning for prompt injection patterns
- Log detailed change diff on dynamic tool refresh at WARNING level

Skill Manager:
- Fix dangerous verdict bug: agent-created skills with dangerous
  findings were silently allowed (ask->None->allow). Now blocked.
2026-04-14 14:23:37 -07:00
Teknium
9bbf7659e9 chore: add Roy-oss1 to AUTHOR_MAP 2026-04-14 14:22:11 -07:00
Roy-oss1
1aa76620d4 fix(feishu): keep approval clicks synchronized with callback card state
Feishu approval clicks need the resolved card to come back from the
synchronous callback path itself. Leaving approval resolution to the
generic asynchronous card-action flow made button feedback depend on
later loop work instead of the callback response the client is waiting
for.

Change-Id: I574997cbbcaa097fdba759b47367e28d1b56b040
Constraint: Feishu card-action callbacks must acknowledge quickly and reflect final approval state from the callback response path
Rejected: Keep approval handling on the generic async card-action route | leaves card state synchronization vulnerable to callback timing and follow-up update ordering
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep approval callback response construction separate from async queue unblocking unless Feishu callback semantics change
Tested: pytest tests/gateway/test_feishu.py tests/gateway/test_feishu_approval_buttons.py tests/gateway/test_approve_deny_commands.py tests/gateway/test_slack_approval_buttons.py tests/gateway/test_telegram_approval_buttons.py -q
Not-tested: Live Feishu workspace end-to-end callback rendering
2026-04-14 14:22:11 -07:00
Teknium
fa8c448f7d fix: notify active sessions on gateway shutdown + update health check
Three fixes for gateway lifecycle stability:

1. Notify active sessions before shutdown (#new)
   When the gateway receives SIGTERM or /restart, it now sends a
   notification to every chat with an active agent BEFORE starting
   the drain. Users see:
   - Shutdown: 'Gateway shutting down — your task will be interrupted.'
   - Restart: 'Gateway restarting — use /retry after restart to continue.'
   Deduplicates per-chat so group sessions with multiple users get
   one notification. Best-effort: send failures are logged and swallowed.

2. Skip .clean_shutdown marker when drain timed out
   Previously, a graceful SIGTERM always wrote .clean_shutdown, even if
   agents were force-interrupted when the drain timed out. This meant
   the next startup skipped session suspension, leaving interrupted
   sessions in a broken state (trailing tool response, no final message).
   Now the marker is only written if the drain completed without timeout,
   so interrupted sessions get properly suspended on next startup.

3. Post-restart health check for hermes update (#6631)
   cmd_update() now verifies the gateway actually survived after
   systemctl restart (sleep 3s + is-active check). If the service
   crashed immediately, it retries once. If still dead, prints
   actionable diagnostics (journalctl command, manual restart hint).

Also closes #8104 — already fixed on main (the /restart handler
correctly detects systemd via INVOCATION_ID and uses via_service=True).

Test plan:
- 6 new tests for shutdown notifications (dedup, restart vs shutdown
  messaging, sentinel filtering, send failure resilience)
- Existing restart drain + update tests pass (47 total)
2026-04-14 14:21:57 -07:00
Teknium
95d11dfd8e docs: automation templates gallery + comparison post (#9821)
Some checks failed
Deploy Site / build-and-deploy (push) Has been skipped
Docker Build and Publish / build-and-push (push) Has been skipped
Nix / nix (ubuntu-latest) (push) Failing after 2s
Tests / e2e (push) Successful in 2m6s
Tests / test (push) Failing after 17m54s
Nix / nix (macos-latest) (push) Has been cancelled
Build Skills Index / deploy-with-index (push) Has been skipped
Build Skills Index / build-index (push) Has been skipped
* feat(skills): add fitness-nutrition skill to optional-skills

Cherry-picked from PR #9177 by @haileymarshall.

Adds a fitness and nutrition skill for gym-goers and health-conscious users:
- Exercise search via wger API (690+ exercises, free, no auth)
- Nutrition lookup via USDA FoodData Central (380K+ foods, DEMO_KEY fallback)
- Offline body composition calculators (BMI, TDEE, 1RM, macros, body fat %)
- Pure stdlib Python, no pip dependencies

Changes from original PR:
- Moved from skills/ to optional-skills/health/ (correct location)
- Fixed BMR formula in FORMULAS.md (removed confusing -5+10, now just +5)
- Fixed author attribution to match PR submitter
- Marked USDA_API_KEY as optional (DEMO_KEY works without signup)

Also adds optional env var support to the skill readiness checker:
- New 'optional: true' field in required_environment_variables entries
- Optional vars are preserved in metadata but don't block skill readiness
- Optional vars skip the CLI capture prompt flow
- Skills with only optional missing vars show as 'available' not 'setup_needed'

* docs: add automation templates gallery and comparison post

- New docs page: guides/automation-templates.md with 15+ ready-to-use
  automation recipes covering development workflow, devops, research,
  GitHub events, and business operations
- Comparison post (hermes-already-has-routines.md) showing Hermes has
  had schedule/webhook/API triggers since March 2026
- Added automation-templates to sidebar navigation

---------

Co-authored-by: haileymarshall <haileymarshall@users.noreply.github.com>
2026-04-14 12:30:50 -07:00
Teknium
a37a095980 fix: detect qwen-oauth provider via CLI tokens in /model picker
Seed qwen-oauth credentials from resolve_qwen_runtime_credentials() in
_seed_from_singletons(). Users who authenticate via 'qwen auth qwen-oauth'
store tokens in ~/.qwen/oauth_creds.json which the runtime resolver reads
but the credential pool couldn't detect — same gap pattern as copilot.

Uses refresh_if_expiring=False to avoid network calls during discovery.
2026-04-14 11:16:26 -07:00
Marvae
0bd3f521ae fix: detect copilot provider via gh auth token in /model picker
Seed copilot credentials from resolve_copilot_token() in the credential
pool's _seed_from_singletons(), alongside the existing anthropic and
openai-codex seeding logic. This makes copilot appear in the /model
provider picker when the user authenticates solely through gh auth token.

Cherry-picked from PR #9767 by Marvae.
2026-04-14 11:16:26 -07:00
Teknium
3e0bccc54c fix: update existing webhook tests to use _webhook_register_url
Follow-up for cherry-picked PR #9746 — three pre-existing tests used
adapter._webhook_url (bare URL) in mock data, but _register_webhook
and _unregister_webhook now compare against _webhook_register_url
(password-bearing URL). Updated to match.
2026-04-14 11:02:48 -07:00
cypres0099
326cbbe40e fix(gateway/bluebubbles): embed password in registered webhook URL for inbound auth
When BlueBubbles posts webhook events to the adapter, it uses the exact
URL registered via /api/v1/webhook — and BB's registration API does not
support custom headers. The adapter currently registers the bare URL
(no credentials), but then requires password auth on inbound POSTs,
rejecting every webhook with HTTP 401.

This is masked on fresh BB installs by a race condition: the webhook
might register once with a prior (possibly patched) URL and keep working
until the first restart. On v0.9.0, _unregister_webhook runs on clean
shutdown, so the next startup re-registers with the bare URL and the
401s begin. Users see the bot go silent with no obvious cause.

Root cause: there's no way to pass auth credentials from BB to the
webhook handler except via the URL itself. BB accepts query params and
preserves them on outbound POSTs.

## Fix

Introduce `_webhook_register_url` — the URL handed to BB's registration
API, with the configured password appended as a `?password=<value>`
query param. The existing webhook auth handler already accepts this
form (it reads `request.query.get("password")`), so no change to the
receive side is needed.

The bare `_webhook_url` is still used for logging and for binding the
local listener, so credentials don't leak into log output. Only the
registration/find/unregister paths use the password-bearing form.

## Notes

- Password is URL-encoded via urllib.parse.quote, handling special
  characters (&, *, @, etc.) that would otherwise break parsing.
- Storing the password in BB's webhook table is not a new disclosure:
  anyone with access to that table already has the BB admin password
  (same credential used for every other API call).
- If `self.password` is empty (no auth configured), the register URL
  is the bare URL — preserves current behavior for unauthenticated
  local-only setups.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 11:02:48 -07:00
cypres0099
8b52356849 fix(gateway/bluebubbles): fall back to data.chats[0].guid when chatGuid missing
BlueBubbles v1.9+ webhook payloads for new-message events do not always
include a top-level chatGuid field on the message data object. Instead,
the chat GUID is nested under data.chats[0].guid.

The adapter currently checks five top-level fallback locations (record and
payload, snake_case and camelCase, plus payload.guid) but never looks
inside the chats array. When none of those top-level fields contain the
GUID, the adapter falls through to using the sender's phone/email as the
session chat ID.

This causes two observable bugs when a user is a participant in both a DM
and a group chat with the bot:

1. DM and group sessions merge. Every message from that user ends up with
   the same session_chat_id (their own address), so the bot cannot
   distinguish which thread the message came from.

2. Outbound routing becomes ambiguous. _resolve_chat_guid() iterates all
   chats and returns the first one where the address appears as a
   participant; group chats typically sort ahead of DMs by activity, so
   replies and cron messages intended for the DM can land in a group.

This was observed in production: a user's morning brief cron delivered to
a group chat with his spouse instead of his DM thread.

The fix adds a single fallback that extracts chat_guid from
record["chats"][0]["guid"] when the top-level fields are empty. The chats
array is included in every new-message webhook payload in BB v1.9.9
(verified against a live server). It is backwards compatible: if a future
BB version starts including chatGuid at the top level, that still wins.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 11:02:48 -07:00
cypres0099
064f8d74de fix(gateway/bluebubbles): remove invalid "message" from webhook event registration
The BlueBubbles adapter registers its webhook with three events:
["new-message", "updated-message", "message"]. The third, "message",
is not a valid event type in the BlueBubbles server API — BB rejects
the registration payload with HTTP 400 Bad Request.

Currently this is masked by the "crash resilience" check in
_register_webhook, which reuses any existing registration matching the
webhook URL and short-circuits before reaching the API call. So an
already-registered webhook from a prior run keeps working. But any fresh
install, or any restart after _unregister_webhook has run during a clean
shutdown, fails to re-register and silently stops receiving messages.

Observed in production: after a gateway restart in v0.9.0 (which auto-
unregisters on shutdown), the next startup hit this 400 and the bot went
silent until the invalid event was removed.

BlueBubbles documents "new-message" and "updated-message" as the message
event types (see https://docs.bluebubbles.app/). There is no "message"
event, and no harm in dropping it — the two remaining events cover all
inbound message webhooks.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 11:02:48 -07:00
Teknium
99bcc2de5b fix(security): harden dashboard API against unauthenticated access (#9800)
Addresses responsible disclosure from FuzzMind Security Lab (CVE pending).

The web dashboard API server had 36 endpoints, of which only 5 checked
the session token. The token itself was served from an unauthenticated
GET /api/auth/session-token endpoint, rendering the protection circular.
When bound to 0.0.0.0 (--host flag), all API keys, config, and cron
management were accessible to any machine on the network.

Changes:
- Add auth middleware requiring session token on ALL /api/ routes except
  a small public whitelist (status, config/defaults, config/schema,
  model/info)
- Remove GET /api/auth/session-token endpoint entirely; inject the token
  into index.html via a <script> tag at serve time instead
- Replace all inline token comparisons (!=) with hmac.compare_digest()
  to prevent timing side-channel attacks
- Block non-localhost binding by default; require --insecure flag to
  override (with warning log)
- Update frontend fetchJSON() to send Authorization header on all
  requests using the injected window.__HERMES_SESSION_TOKEN__

Credit: Callum (@0xca1x) and @migraine-sudo at FuzzMind Security Lab
2026-04-14 10:57:56 -07:00
asheriif
b583210c97 fix(gateway): fix regression causing display.streaming to override root streaming key 2026-04-14 10:52:23 -07:00
Teknium
8bb5973950 docs: add proxy mode documentation
- Matrix docs: full Proxy Mode section with architecture diagram,
  step-by-step setup (host + Docker), docker-compose.yml/Dockerfile
  examples, configuration reference, and limitations notes
- API Server docs: add Proxy Mode section explaining the api_server
  serves as the backend for gateway proxy mode
- Environment variables reference: add GATEWAY_PROXY_URL and
  GATEWAY_PROXY_KEY entries
2026-04-14 10:49:48 -07:00
Teknium
90c98345c9 feat: gateway proxy mode — forward messages to remote API server
When GATEWAY_PROXY_URL (or gateway.proxy_url in config.yaml) is set,
the gateway becomes a thin relay: it handles platform I/O (encryption,
threading, media) and delegates all agent work to a remote Hermes API
server via POST /v1/chat/completions with SSE streaming.

This enables the primary use case of running a Matrix E2EE gateway in
Docker on Linux while the actual agent runs on the host (e.g. macOS)
with full access to local files, memory, skills, and a unified session
store. Works for any platform adapter, not just Matrix.

Configuration:
  - GATEWAY_PROXY_URL env var (Docker-friendly)
  - gateway.proxy_url in config.yaml
  - GATEWAY_PROXY_KEY env var for API auth (matches API_SERVER_KEY)
  - X-Hermes-Session-Id header for session continuity

Architecture:
  - _get_proxy_url() checks env var first, then config.yaml
  - _run_agent_via_proxy() handles HTTP forwarding with SSE streaming
  - _run_agent() delegates to proxy path when URL is configured
  - Platform streaming (GatewayStreamConsumer) works through proxy
  - Returns compatible result dict for session store recording

Files changed:
  - gateway/run.py: proxy mode implementation (~250 lines)
  - hermes_cli/config.py: GATEWAY_PROXY_URL + GATEWAY_PROXY_KEY env vars
  - tests/gateway/test_proxy_mode.py: 17 tests covering config
    resolution, dispatch, HTTP forwarding, error handling, message
    filtering, and result shape validation

Closes discussion from Cars29 re: Matrix gateway mixed-mode issue.
2026-04-14 10:49:48 -07:00
zhiheng.liu
1ace9b4dc4 fix: memory_setup.py - write non-secret env vars, check all fields in status
Critical bug fixes only (no redundant changes):

1. **Write non-secret fields to .env** - Add non-secret fields with env_var to env_writes so they get saved to .env
2. **Status checks all fields** - Check all fields with env_var (both secret and non-secret), not just secrets

Fixes:
- OPENVIKING_ENDPOINT and similar non-secret env vars now get written to .env
- hermes memory status now shows ALL missing required fields
2026-04-14 10:49:35 -07:00
dirtyfancy
e964cfc403 fix(gateway): trigger memory provider shutdown on /new and /reset
The /new and /reset commands were not calling shutdown_memory_provider()
on the cached agent before eviction. This caused OpenViking (and any
memory provider that relies on session-end shutdown) to skip commit,
leaving memories un-indexed until idle timeout or gateway shutdown.

Add the missing shutdown_memory_provider() call in _handle_reset_command(),
matching the behavior already present in the session expiry watcher.

Fixes #7759
2026-04-14 10:49:35 -07:00
Disaster-Terminator
9bdfcd1b93 feat: sort tool search results by score and add corresponding unit test 2026-04-14 10:49:35 -07:00
Teknium
b867171291 fix: preserve profile name completion in dynamic shell completion
The dynamic parser walker from the contributor's commit lost the profile
name tab-completion that existed in the old static generators. This adds
it back for all three shells:

- Bash: _hermes_profiles() helper, -p/--profile completion, profile
  action→name completion (use/delete/show/alias/rename/export)
- Zsh: _hermes_profiles() function, -p/--profile argument spec, profile
  action case with name completion
- Fish: __hermes_profiles function, -s p -l profile flag, profile action
  completions

Also removes the dead fallback path in cmd_completion() that imported
the old static generators from profiles.py (parser is always available
via the lambda wiring) and adds 11 regression-prevention tests for
profile completion.
2026-04-14 10:45:42 -07:00
leozeli
c95b1c5096 fix(install): add fish shell support in install.sh
Fish users' $SHELL is /usr/bin/fish, which fell into the '*' case and
incorrectly wrote 'export PATH=...' to ~/.bashrc and ~/.zshrc — neither
of which fish reads.

- setup_path(): add fish) case that writes fish_add_path to
  ~/.config/fish/config.fish (fish-compatible PATH syntax)
- setup_path(): skip ~/.profile for fish (not sourced by fish)
- print_success(): show correct reload instruction for fish:
  source ~/.config/fish/config.fish

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 10:45:42 -07:00
leozeli
a686dbdd26 feat(cli): add dynamic shell completion for bash, zsh, and fish
Replaces the hardcoded completion stubs in profiles.py with a dynamic
generator that walks the live argparse parser tree at runtime.

- New hermes_cli/completion.py: _walk() recursively extracts all
  subcommands and flags; generate_bash/zsh/fish() produce complete
  scripts with nested subcommand support
- cmd_completion now accepts the parser via closure so completions
  always reflect the actual registered commands (including plugin-
  registered ones like honcho)
- completion subcommand now accepts bash | zsh | fish (fish requested
  in issue comments)
- Fix _SUBCOMMANDS set: add honcho, claw, plugins, acp, webhook,
  memory, dump, debug, backup, import, completion, logs so that
  multi-word session names after -c/-r are not broken by these commands
- Add tests/hermes_cli/test_completion.py: 17 tests covering parser
  extraction, alias deduplication, bash/zsh/fish output content,
  bash syntax validation, fish syntax validation, and subcommand
  drift prevention

Tested on Linux (Arch). bash and fish completion verified live.
zsh script passes syntax check (zsh not installed on test machine).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 10:45:42 -07:00
N0nb0at
b21b3bfd68 feat(plugins): namespaced skill registration for plugin skill bundles
Add ctx.register_skill() API so plugins can ship SKILL.md files under
a 'plugin:skill' namespace, preventing name collisions with built-in
Hermes skills. skill_view() detects the ':' separator and routes to
the plugin registry while bare names continue through the existing
flat-tree scan unchanged.

Key additions:
- agent/skill_utils: parse_qualified_name(), is_valid_namespace()
- hermes_cli/plugins: PluginContext.register_skill(), PluginManager
  skill registry (find/list/remove)
- tools/skills_tool: qualified name dispatch in skill_view(),
  _serve_plugin_skill() with full guards (disabled, platform,
  injection scan), bundle context banner with sibling listing,
  stale registry self-heal
- Hoisted _INJECTION_PATTERNS to module level (dedup)
- Updated skill_view schema description

Based on PR #9334 by N0nb0at. Lean P1 salvage — omits autogen shim
(P2) for a simpler first merge.

Closes #8422
2026-04-14 10:42:58 -07:00
Dusk1e
4b47856f90 fix: load credentials from HERMES_HOME .env in trajectory_compressor 2026-04-14 10:24:19 -07:00
Teknium
8a002d4efc chore: add ChimingLiu to AUTHOR_MAP 2026-04-14 10:22:11 -07:00
Teknium
8ea9ceb44c fix: guard reply_to_text against DeletedReferencedMessage
Use getattr() for resolved.content since discord.py's
DeletedReferencedMessage lacks a content attribute. Adds test
for the deleted-message edge case.
2026-04-14 10:22:11 -07:00
ChimingLiu
7636baf49c feat(discord): extract reply text from message references 2026-04-14 10:22:11 -07:00
Teknium
0e7dd30acc fix(browser): fix Camofox JS eval endpoint, userId, and package rename (#9774)
- Fix _camofox_eval() endpoint: /tabs/{id}/eval → /tabs/{id}/evaluate
  (correct Camofox REST API path)
- Add required userId field to JS eval request body (all other Camofox
  endpoints already include it)
- Update npm package from @askjo/camoufox-browser ^1.0.0 to
  @askjo/camofox-browser ^1.5.2 (upstream package was renamed)
- Update tools_config.py post-setup to reference new package directory
  and npx command
- Bump Node engine requirement from >=18 to >=20 (required by
  camoufox-js dependency in camofox-browser v1.5.2)
- Regenerate package-lock.json

Fixes issues reported in PRs #9472, #8267, #7208 (stale).
2026-04-14 10:21:54 -07:00
Teknium
5f36b42b2e fix: nest msvcrt import inside fcntl except block
Match cron/scheduler.py pattern — only attempt msvcrt import when
fcntl is unavailable. Pre-declare msvcrt = None at module level so
_file_lock() references don't NameError on Linux.
2026-04-14 10:18:05 -07:00
Dusk1e
420d27098f fix(tools): keep memory tool available when fcntl is unavailable 2026-04-14 10:18:05 -07:00
Zhuofeng Wang
449c17e9a9 fix(gateway): support Telegram MarkdownV2 expandable blockquotes 2026-04-14 10:16:49 -07:00
shijianzhi
70611879de fix(cli): fix doctor checks for Kimi China credentials 2026-04-14 10:16:30 -07:00
Austin Pickett
206259d111 Merge pull request #9701 from NousResearch/fix/dashboard-routing-v2
feat(web): re-apply dashboard UI improvements on top of i18n
2026-04-14 08:46:17 -07:00
Austin Pickett
4ffaac542b fix(web): i18n fixes for sidebar and dropdown labels
- Add missing translation keys: skills.resultCount, skills.toolsetLabel
- Replace hardcoded "result(s)" and "toolset" with translated strings
- Fix stale useMemo in SkillsPage allCategories (missing `t` dependency)
  causing sidebar category names to stay in English after language switch

Made-with: Cursor
2026-04-14 10:32:51 -04:00
Austin Pickett
e88aa8a58c feat(web): re-apply dashboard UI improvements on top of i18n
Re-applies changes from #9471 that were overwritten by the i18n PR:

- URL-based routing via react-router-dom (NavLink, Routes, BrowserRouter)
- Replace emoji icons with lucide-react in ConfigPage and SkillsPage
- Sidebar layout for ConfigPage, SkillsPage, and LogsPage
- Custom dropdown Select component (SelectOption) in CronPage
- Remove all non-functional rounded borders across the UI
- Fixed header with proper content offset

Made-with: Cursor
2026-04-14 10:23:43 -04:00
Ben Barclay
16f9d02084 Merge pull request #9475 from NousResearch/docs/fix-docker-version-command
docs: update docker version check command
2026-04-14 20:27:24 +10:00
Teknium
7ad47ace51 fix: resolve remaining 4 CI test failures (#9543)
- test_auth_commands: suppress _seed_from_singletons auto-seeding that
  adds extra credentials from CI env (same pattern as nearby tests)
- test_interrupt: clear stale _interrupted_threads set to prevent
  thread ident reuse from prior tests in same xdist worker
- test_code_execution: add watch_patterns to _BLOCKED_TERMINAL_PARAMS
  to match production _TERMINAL_BLOCKED_PARAMS
2026-04-14 02:18:38 -07:00
Teknium
b4fcec6412 fix: prevent streaming cursor from appearing as standalone messages (#9538)
During rapid tool-calling, the model often emits 1-2 tokens before
switching to tool calls. The stream consumer would create a new message
with 'X ▉' (short text + cursor), and if the follow-up edit to strip
the cursor was rate-limited by the platform, the cursor remained as
a permanent standalone message — reported on Telegram as 'white box'
artifacts.

Add a minimum-content guard in _send_or_edit: when creating a new
standalone message (no existing message_id), require at least 4
visible characters alongside the cursor before sending. Shorter text
accumulates into the next streaming segment instead.

This prevents cursor-only 'tofu' messages across all platforms without
affecting normal streaming (edits to existing messages, final sends
without cursor, and messages with substantial text are all unaffected).

Reported by @michalkomar on X.
2026-04-14 01:52:42 -07:00
Teknium
2558d28a9b fix: resolve CI test failures — add missing functions, fix stale tests (#9483)
Production fixes:
- Add clear_session_context() to hermes_logging.py (fixes 48 teardown errors)
- Add clear_session() to tools/approval.py (fixes 9 setup errors)
- Add SyncError M_UNKNOWN_TOKEN check to Matrix _sync_loop (bug fix)
- Fall back to inline api_key in named custom providers when key_env
  is absent (runtime_provider.py)

Test fixes:
- test_memory_user_id: use builtin+external provider pair, fix honcho
  peer_name override test to match production behavior
- test_display_config: remove TestHelpers for non-existent functions
- test_auxiliary_client: fix OAuth tokens to match _is_oauth_token
  patterns, replace get_vision_auxiliary_client with resolve_vision_provider_client
- test_cli_interrupt_subagent: add missing _execution_thread_id attr
- test_compress_focus: add model/provider/api_key/base_url/api_mode
  to mock compressor
- test_auth_provider_gate: add autouse fixture to clean Anthropic env
  vars that leak from CI secrets
- test_opencode_go_in_model_list: accept both 'built-in' and 'hermes'
  source (models.dev API unavailable in CI)
- test_email: verify email Platform enum membership instead of source
  inspection (build_channel_directory now uses dynamic enum loop)
- test_feishu: add bot_added/bot_deleted handler mocks to _Builder
- test_ws_auth_retry: add AsyncMock for sync_store.get_next_batch,
  add _pending_megolm and _joined_rooms to Matrix adapter mocks
- test_restart_drain: monkeypatch-delete INVOCATION_ID (systemd sets
  this in CI, changing the restart call signature)
- test_session_hygiene: add user_id to SessionSource
- test_session_env: use relative baseline for contextvar clear check
  (pytest-xdist workers share context)
2026-04-14 01:43:45 -07:00
Jiawen-lee
2cfd2dafc6 feat(gateway): add ignored_threads config for Telegram 2026-04-14 01:40:32 -07:00
Teknium
1acf81fdf5 docs: add QQBot to all 14 docs pages (full platform parity)
- sidebars.ts: sidebar navigation entry
- webhooks.md: deliver field routing table
- configuration.md: platform keys list
- sessions.md: platform identifiers table
- features/cron.md: delivery target table
- developer-guide/architecture.md: adapter listing
- developer-guide/cron-internals.md: delivery target table
- developer-guide/gateway-internals.md: file tree listing
- guides/cron-troubleshooting.md: supported platforms list
- integrations/index.md: platform links list
- reference/toolsets-reference.md: toolset table

(qqbot.md, environment-variables.md, and messaging/index.md were
already included in the contributor's original PR)
2026-04-14 00:11:49 -07:00
Teknium
8d545da3ff fix: add platform lock, send retry, message splitting, REST one-shot, shared strip_markdown
Improvements from our earlier #8269 salvage work applied to #7616:

- Platform token lock: acquire_scoped_lock/release_scoped_lock prevents
  two profiles from double-connecting the same QQ bot simultaneously
- Send retry with exponential backoff (3 attempts, 1s/2s/4s) with
  permanent vs transient error classification (matches Telegram pattern)
- Proper long-message splitting via truncate_message() instead of
  hard-truncating at MAX_MESSAGE_LENGTH (preserves code blocks, adds 1/N)
- REST-based one-shot send in send_message_tool — uses QQ Bot REST API
  directly with httpx instead of creating a full WebSocket adapter per
  message (fixes the connect→send race condition)
- Use shared strip_markdown() from helpers.py instead of 15 lines of
  inline regex with import-inside-method (DRY, same as BlueBubbles/SMS)
- format_message() now wired into send() pipeline
2026-04-14 00:11:49 -07:00
Teknium
4654f75627 fix: QQBot missing integration points, timestamp parsing, test fix
- Add Platform.QQBOT to _UPDATE_ALLOWED_PLATFORMS (enables /update command)
- Add 'qqbot' to webhook cross-platform delivery routing
- Add 'qqbot' to hermes dump platform detection
- Fix test_name_property casing: 'QQBot' not 'QQBOT'
- Add _parse_qq_timestamp() for ISO 8601 + integer ms compatibility
  (QQ API changed timestamp format — from PR #2411 finding)
- Wire timestamp parsing into all 4 message handlers
2026-04-14 00:11:49 -07:00
walli
884cd920d4 feat(gateway): unify QQBot branding, add PLATFORM_HINTS, fix streaming, restore missing setup functions
- Rename platform from 'qq' to 'qqbot' across all integration points
  (Platform enum, toolset, config keys, import paths, file rename qq.py → qqbot.py)
- Add PLATFORM_HINTS for QQBot in prompt_builder (QQ supports markdown)
- Set SUPPORTS_MESSAGE_EDITING = False to skip streaming on QQ
  (prevents duplicate messages from non-editable partial + final sends)
- Add _send_qqbot() standalone send function for cron/send_message tool
- Add interactive _setup_qq() wizard in hermes_cli/setup.py
- Restore missing _setup_signal/email/sms/dingtalk/feishu/wecom/wecom_callback
  functions that were lost during the original merge
2026-04-14 00:11:49 -07:00
Junjun Zhang
87bfc28e70 feat: add QQ Bot platform adapter (Official API v2)
Add full QQ Bot integration via the Official QQ Bot API (v2):
- WebSocket gateway for inbound events (C2C, group, guild, DM)
- REST API for outbound text/markdown/media messages
- Voice transcription (Tencent ASR + configurable STT provider)
- Attachment processing (images, voice, files)
- User authorization (allowlist + allow-all + DM pairing)

Integration points:
- gateway: Platform.QQ enum, adapter factory, allowlist maps
- CLI: setup wizard, gateway config, status display, tools config
- tools: send_message cross-platform routing, toolsets
- cron: delivery platform support
- docs: QQ Bot setup guide
2026-04-14 00:11:49 -07:00
Teknium
eb44abd6b1 feat: improve file search UX — fuzzy @ completions, mtime sorting, better suggestions (#9467)
Three improvements to file search based on user feedback:

1. Fuzzy @ completions (commands.py):
   - Bare @query now does project-wide fuzzy file search instead of
     prefix-only directory listing
   - Uses rg --files with 5-second cache for responsive completions
   - Scoring: exact name (100) > prefix (80) > substring (60) >
     path contains (40) > subsequence with boundary bonus (35/25)
   - Bare @ with no query shows recently modified files first

2. Mtime-sorted file search (file_operations.py):
   - _search_files_rg now uses --sortr=modified (rg 13+) to surface
     recently edited files first
   - Falls back to unsorted on older rg versions

3. Improved file-not-found suggestions (file_operations.py):
   - Replaced crude character-set overlap with ranked scoring:
     same basename (90) > prefix (70) > substring (60) >
     reverse substring (40) > same extension (30)
   - search_files path-not-found now suggests similar directories
     from the parent
2026-04-13 23:54:45 -07:00
Greer Guthrie
c7e2fe655a fix: make tool registry reads thread-safe 2026-04-13 23:52:32 -07:00
Teknium
6dc8f8e9c0 feat(skin): add warm-lightmode skin from PR #4811
Add a second light-mode skin option with warm brown/parchment tones,
adapted from ygd58's contribution in PR #4811. Includes completion
menu and status bar color keys for full light-terminal support.

Co-authored-by: buray <78954051+ygd58@users.noreply.github.com>
2026-04-13 23:51:21 -07:00
Liu Chongwei
bc93641c4f feat(skins): add built-in daylight skin 2026-04-13 23:51:21 -07:00
Ben Barclay
9ffc26bc8f docs: update docker version check command
Replace `docker exec hermes hermes version` with
`docker run -it --rm nousresearch/hermes-agent:latest version`
2026-04-14 06:37:50 +00:00
Teknium
a2ea237db2 feat: add internationalization (i18n) to web dashboard — English + Chinese (#9453)
Add a lightweight i18n system to the web dashboard with English (default) and
Chinese language support. A language switcher with flag icons is placed in the
header bar, allowing users to toggle between languages. The choice persists
to localStorage.

Implementation:
- src/i18n/ — types, translation files (en.ts, zh.ts), React context + hook
- LanguageSwitcher component shows the *other* language's flag as the toggle
- I18nProvider wraps the app in main.tsx
- All 8 pages + OAuth components updated to use t() translation calls
- Zero new dependencies — pure React context + localStorage
2026-04-13 23:19:13 -07:00
Teknium
19199cd38d fix: clamp 'minimal' reasoning effort to 'low' on Responses API (#9429)
GPT-5.4 supports none/low/medium/high/xhigh but not 'minimal'.
Users may configure 'minimal' via OpenRouter conventions, which would
cause a 400 on native OpenAI. Clamp to 'low' in the codex_responses
path before sending.
2026-04-13 23:11:13 -07:00
Teknium
38ad158b6b fix: auto-correct close model name matches in /model validation (#9424)
* feat(skills): add fitness-nutrition skill to optional-skills

Cherry-picked from PR #9177 by @haileymarshall.

Adds a fitness and nutrition skill for gym-goers and health-conscious users:
- Exercise search via wger API (690+ exercises, free, no auth)
- Nutrition lookup via USDA FoodData Central (380K+ foods, DEMO_KEY fallback)
- Offline body composition calculators (BMI, TDEE, 1RM, macros, body fat %)
- Pure stdlib Python, no pip dependencies

Changes from original PR:
- Moved from skills/ to optional-skills/health/ (correct location)
- Fixed BMR formula in FORMULAS.md (removed confusing -5+10, now just +5)
- Fixed author attribution to match PR submitter
- Marked USDA_API_KEY as optional (DEMO_KEY works without signup)

Also adds optional env var support to the skill readiness checker:
- New 'optional: true' field in required_environment_variables entries
- Optional vars are preserved in metadata but don't block skill readiness
- Optional vars skip the CLI capture prompt flow
- Skills with only optional missing vars show as 'available' not 'setup_needed'

* fix: auto-correct close model name matches in /model validation

When a user types a model name with a minor typo (e.g. gpt5.3-codex instead
of gpt-5.3-codex), the validation now auto-corrects to the closest match
instead of accepting the wrong name with a warning.

Uses difflib get_close_matches with cutoff=0.9 to avoid false corrections
(e.g. gpt-5.3 should not silently become gpt-5.4). Applied consistently
across all three validation paths: codex provider, custom endpoints, and
generic API-probed providers.

The validate_requested_model() return dict gains an optional corrected_model
key that switch_model() applies before building the result.

Reported by Discord user — /model gpt5.3-codex was accepted with a warning
but would fail at the API level.

---------

Co-authored-by: haileymarshall <haileymarshall@users.noreply.github.com>
2026-04-13 23:09:39 -07:00
Teknium
35424f8fc1 chore: add bennytimz to AUTHOR_MAP 2026-04-13 23:03:08 -07:00
oluwadareab12
a91b9bb855 feat(skills): add drug-discovery optional skill — ChEMBL, PubChem, OpenFDA, ADMET analysis
Pharmaceutical research skill covering bioactive compound search (ChEMBL),
drug-likeness screening (Lipinski Ro5 + Veber via PubChem), drug-drug
interaction lookups (OpenFDA), gene-disease associations (OpenTargets
GraphQL), and ADMET reasoning guidance. All free public APIs, zero auth,
stdlib-only Python. Includes helper scripts for batch Ro5 screening and
target-to-compound pipelines.

Moved to optional-skills/research/ (niche domain skill, not built-in).
Fixed: authors→author frontmatter, removed unused jq prerequisite,
bare except→except Exception.

Co-authored-by: bennytimz <oluwadareab12@gmail.com>
Salvaged from PR #8695.
2026-04-13 23:03:08 -07:00
Teknium
d631431872 feat: prompt for display name when adding custom providers (#9420)
During custom endpoint setup, users are now asked for a display name
with the auto-generated name as the default. Typing 'Ollama' or
'LM Studio' replaces the generic 'Local (localhost:11434)' in the
provider menu.

Extracts _auto_provider_name() for reuse and adds a name= parameter
to _save_custom_provider() so the caller can pass through the
user-chosen label.
2026-04-13 22:41:00 -07:00
Kenny Xie
cdd44817f2 fix(anthropic): send fast mode speed via extra_body 2026-04-13 22:32:39 -07:00
Teknium
110892ff69 docs: move Xiaomi MiMo up in README provider list 2026-04-13 22:30:44 -07:00
Teknium
3de2b98503 fix(streaming): filter <think> blocks from gateway stream consumer
Models like MiniMax emit inline <think>...</think> reasoning blocks in
their content field. The CLI already suppresses these via a state machine
in _stream_delta, but the gateway's GatewayStreamConsumer had no
equivalent filtering — raw think blocks were streamed directly to
Discord/Telegram/Slack.

The fix adds a _filter_and_accumulate() method that mirrors the CLI's
approach: a state machine tracks whether we're inside a reasoning block
and silently discards the content. Includes the same block-boundary
check (tag must appear at line start or after whitespace-only prefix)
to avoid false positives when models mention <think> in prose.

Handles all tag variants: <think>, <thinking>, <THINKING>, <thought>,
<reasoning>, <REASONING_SCRATCHPAD>.

Also handles edge cases:
- Tags split across streaming deltas (partial tag buffering)
- Unclosed blocks (content suppressed until stream ends)
- Multiple consecutive blocks
- _flush_think_buffer on stream end for held-back partial tags

Adds 22 unit tests + 1 integration test covering all scenarios.
2026-04-13 22:16:20 -07:00
helix4u
e08590888a fix: honor interrupts during MCP tool waits 2026-04-13 22:14:55 -07:00
Teknium
69d619cf89 docs: add Hugging Face and Xiaomi MiMo to README provider list (#9406)
* feat(skills): add fitness-nutrition skill to optional-skills

Cherry-picked from PR #9177 by @haileymarshall.

Adds a fitness and nutrition skill for gym-goers and health-conscious users:
- Exercise search via wger API (690+ exercises, free, no auth)
- Nutrition lookup via USDA FoodData Central (380K+ foods, DEMO_KEY fallback)
- Offline body composition calculators (BMI, TDEE, 1RM, macros, body fat %)
- Pure stdlib Python, no pip dependencies

Changes from original PR:
- Moved from skills/ to optional-skills/health/ (correct location)
- Fixed BMR formula in FORMULAS.md (removed confusing -5+10, now just +5)
- Fixed author attribution to match PR submitter
- Marked USDA_API_KEY as optional (DEMO_KEY works without signup)

Also adds optional env var support to the skill readiness checker:
- New 'optional: true' field in required_environment_variables entries
- Optional vars are preserved in metadata but don't block skill readiness
- Optional vars skip the CLI capture prompt flow
- Skills with only optional missing vars show as 'available' not 'setup_needed'

* docs: add Hugging Face and Xiaomi MiMo to README provider list

---------

Co-authored-by: haileymarshall <haileymarshall@users.noreply.github.com>
2026-04-13 22:12:46 -07:00
haileymarshall
f0b353bade feat(skills): add fitness-nutrition skill to optional-skills
Cherry-picked from PR #9177 by @haileymarshall.

Adds a fitness and nutrition skill for gym-goers and health-conscious users:
- Exercise search via wger API (690+ exercises, free, no auth)
- Nutrition lookup via USDA FoodData Central (380K+ foods, DEMO_KEY fallback)
- Offline body composition calculators (BMI, TDEE, 1RM, macros, body fat %)
- Pure stdlib Python, no pip dependencies

Changes from original PR:
- Moved from skills/ to optional-skills/health/ (correct location)
- Fixed BMR formula in FORMULAS.md (removed confusing -5+10, now just +5)
- Fixed author attribution to match PR submitter
- Marked USDA_API_KEY as optional (DEMO_KEY works without signup)

Also adds optional env var support to the skill readiness checker:
- New 'optional: true' field in required_environment_variables entries
- Optional vars are preserved in metadata but don't block skill readiness
- Optional vars skip the CLI capture prompt flow
- Skills with only optional missing vars show as 'available' not 'setup_needed'
2026-04-13 22:10:00 -07:00
Teknium
62fb6b2cd8 fix: guard zero context length display + add 19 tests for model info
- ModelInfoCard: hide card when effective_context_length <= 0 instead
  of showing 'Context Window: 0 auto-detected'
- Add tests for _normalize_config_for_web model_context_length extraction
- Add tests for _denormalize_config_from_web round-trip (write back,
  remove on zero, upgrade bare string to dict, coerce string input)
- Add tests for CONFIG_SCHEMA ordering (model_context_length after model)
- Add tests for GET /api/model/info endpoint (dict config, bare string,
  empty model, capabilities, graceful error handling)
2026-04-13 22:04:35 -07:00
kshitijk4poor
8fd3093f49 feat(web): add context window support to dashboard config
- Add GET /api/model/info endpoint that resolves model metadata using the
  same 10-step context-length detection chain the agent uses. Returns
  auto-detected context length, config override, effective value, and
  model capabilities (tools, vision, reasoning, max output, model family).

- Surface model.context_length as model_context_length virtual field in
  the config normalize/denormalize cycle. 0 = auto-detect (default),
  positive value overrides. Writing 0 removes context_length from the
  model dict on disk.

- Add ModelInfoCard component showing resolved context window (e.g. '1M
  auto-detected' or '500K override — auto: 1M'), max output tokens, and
  colored capability badges (Tools, Vision, Reasoning, model family).

- Inject ModelInfoCard between model field and context_length override in
  ConfigPage General tab. Card re-fetches on model change and after save.

- Insert model_context_length right after model in CONFIG_SCHEMA ordering
  so the three elements (model input → info card → override) are adjacent.
2026-04-13 22:04:35 -07:00
Gianfranco Piana
eabc0a2f66 feat(plugins): let pre_tool_call hooks block tool execution
Plugins can now return {"action": "block", "message": "reason"} from
their pre_tool_call hook to prevent a tool from executing. The error
message is returned to the model as a tool result so it can adjust.

Covers both execution paths: handle_function_call (model_tools.py) and
agent-level tools (run_agent.py _invoke_tool + sequential/concurrent).
Blocked tools skip all side effects (counter resets, checkpoints,
callbacks, read-loop tracker).

Adds skip_pre_tool_call_hook flag to avoid double-firing the hook when
run_agent.py already checked and then calls handle_function_call.

Salvaged from PR #5385 (gianfrancopiana) and PR #4610 (oredsecurity).
2026-04-13 22:01:49 -07:00
Austin Pickett
ea74f61d98 Merge pull request #9370 from NousResearch/fix/dashboard-routing
feat: react-router, sidebar layout, sticky header, dropdown component…
2026-04-13 21:23:48 -07:00
Teknium
943c01536f feat: add openrouter/elephant-alpha to curated model lists (#9378)
* Add hermes debug share instructions to all issue templates

- bug_report.yml: Add required Debug Report section with hermes debug share
  and /debug instructions, make OS/Python/Hermes version optional (covered
  by debug report), demote old logs field to optional supplementary
- setup_help.yml: Replace hermes doctor reference with hermes debug share,
  add Debug Report section with fallback chain (debug share -> --local -> doctor)
- feature_request.yml: Add optional Debug Report section for environment context

All templates now guide users to run hermes debug share (or /debug in chat)
and paste the resulting paste.rs links, giving maintainers system info,
config, and recent logs in one step.

* feat: add openrouter/elephant-alpha to curated model lists

- Add to OPENROUTER_MODELS (free, positioned above GPT models)
- Add to _PROVIDER_MODELS["nous"] mirror list
- Add 256K context window fallback in model_metadata.py
2026-04-13 21:16:14 -07:00
Teknium
dd86deef13 feat(ci): add contributor attribution check on PRs (#9376)
Adds a CI workflow that blocks PRs introducing commits with
unmapped author emails. Checks each new commit's author email
against AUTHOR_MAP in scripts/release.py — GitHub noreply emails
auto-pass, but personal/work emails must be mapped.

Also adds --strict and --diff-base flags to contributor_audit.py
for programmatic use. --strict exits 1 when new unmapped emails
are found; --diff-base scopes the check to only flag emails from
commits after a given ref (grandfathers existing unknowns).

Prevention for the 97-unmapped-email gap found in the April 2026
contributor audit.
2026-04-13 21:13:08 -07:00
Teknium
5719c1f391 fix: add 75 contributor email→username mappings + .mailmap (#9358)
Audit of all external contributor PRs revealed 97 commit emails
not mapped in AUTHOR_MAP, meaning contributors weren't properly
credited in release notes. Cross-referenced via:
- GitHub API email search (9 resolved before rate limit)
- Salvage PR body mentions (@username in descriptions)
- Git noreply email cross-reference (same person, both emails)
- GH contributor list username matching

Also adds .mailmap for git shortlog/log display consistency.

Remaining 22 unmapped emails need GH API resolution when rate
limit resets — the contributor_audit.py script will flag them.

Addresses ColourfulWhite's report about missing contributor tags.
2026-04-13 21:10:39 -07:00
Austin Pickett
bc3844c907 feat: react-router, sidebar layout, sticky header, dropdown component, remove emojis, rounded corners 2026-04-14 00:01:18 -04:00
Teknium
5621fc449a chore: rename AI Gateway → Vercel AI Gateway, move Xiaomi to #5 (#9326)
- Rename 'AI Gateway' to 'Vercel AI Gateway' across auth, models,
  doctor, setup, and tests.
- Move Xiaomi MiMo to position #5 in the provider picker.
2026-04-13 19:51:54 -07:00
Teknium
0cc7f79016 fix(streaming): prevent duplicate Telegram replies when stream task is cancelled (#9319)
When the 5-second stream_task timeout in gateway/run.py expires (due to
slow Telegram API calls from rate limiting after several messages), the
stream consumer is cancelled via asyncio.CancelledError. The
CancelledError handler did a best-effort final edit but never set
final_response_sent, so the gateway fell through to the normal send path
and delivered the full response again as a reply — causing a duplicate.

The fix: in the CancelledError handler, set final_response_sent = True
when already_sent is True (i.e., the stream consumer had already
delivered content to the user). This tells the gateway's already_sent
check that the response was delivered, preventing the duplicate send.

Adds two tests verifying the cancellation behavior:
- Cancelled with already_sent=True → final_response_sent=True (no dup)
- Cancelled with already_sent=False → final_response_sent=False (normal
  send path proceeds)

Reported by community user hume on Discord.
2026-04-13 19:22:43 -07:00
Teknium
d15efc9c1b fix: correct GPT-5 family context lengths in fallback defaults (#9309)
The generic 'gpt-5' fallback was set to 128,000 — which is the max
OUTPUT tokens, not the context window. GPT-5 base and most variants
(codex, mini) have 400,000 context. This caused /model to report
128k for models like gpt-5.3-codex when models.dev was unavailable.

Added specific entries for GPT-5 variants with different context sizes:
- gpt-5.4, gpt-5.4-pro: 1,050,000 (1.05M)
- gpt-5.4-mini, gpt-5.4-nano: 400,000
- gpt-5.3-codex-spark: 128,000 (reduced)
- gpt-5.1-chat: 128,000 (chat variant)
- gpt-5 (catch-all): 400,000

Sources: https://developers.openai.com/api/docs/models
2026-04-13 19:22:23 -07:00
Teknium
f6626fccee refactor: remove provider tier system — flat picker in hermes model (#9303)
Remove the two-tier (top/extended) provider picker that hid most
providers behind a 'More providers...' submenu. All providers now
appear in a single flat list.

- Remove tier field from ProviderEntry namedtuple
- Remove tier values from all CANONICAL_PROVIDERS entries
- Flatten the hermes model picker (no more 'More...' submenu)
- Move 'Custom endpoint' to the bottom of the main list
2026-04-13 18:51:13 -07:00
Teknium
f324222b79 fix: add vLLM/local server error patterns + MCP initial connection retry (#9281)
Port two improvements inspired by Kilo-Org/kilocode analysis:

1. Error classifier: add context overflow patterns for vLLM, Ollama,
   and llama.cpp/llama-server. These local inference servers return
   different error formats than cloud providers (e.g., 'exceeds the
   max_model_len', 'context length exceeded', 'slot context'). Without
   these patterns, context overflow errors from local servers are
   misclassified as format errors, causing infinite retries instead
   of triggering compression.

2. MCP initial connection retry: previously, if the very first
   connection attempt to an MCP server failed (e.g., transient DNS
   blip at startup), the server was permanently marked as failed with
   no retry. Post-connect reconnection had 5 retries with exponential
   backoff, but initial connection had zero. Now initial connections
   retry up to 3 times with backoff before giving up, matching the
   resilience of post-connect reconnection.
   (Inspired by Kilo Code's MCP server disappearing fix in v1.3.3)

Tests: 6 new error classifier tests, 4 new MCP retry tests, 1
updated existing test. All 276 affected tests pass.
2026-04-13 18:46:14 -07:00
arthurbr11
0a4cf5b3e1 feat(providers): add Arcee AI as direct API provider
Adds Arcee AI as a standard direct provider (ARCEEAI_API_KEY) with
Trinity models: trinity-large-thinking, trinity-large-preview, trinity-mini.

Standard OpenAI-compatible provider checklist: auth.py, config.py,
models.py, main.py, providers.py, doctor.py, model_normalize.py,
model_metadata.py, setup.py, trajectory_compressor.py.

Based on PR #9274 by arthurbr11, simplified to a standard direct
provider without dual-endpoint OpenRouter routing.
2026-04-13 18:40:06 -07:00
Agent
78fa758451 feat(web): make Web UI responsive for mobile
Some checks failed
Docker Build and Publish / build-and-push (pull_request) Has been skipped
Nix / nix (ubuntu-latest) (pull_request) Failing after 10s
Supply Chain Audit / Scan PR for supply chain risks (pull_request) Successful in 1m0s
Docs Site Checks / docs-site-checks (pull_request) Failing after 4m1s
Tests / e2e (pull_request) Successful in 2m26s
Tests / test (pull_request) Failing after 36m31s
Nix / nix (macos-latest) (pull_request) Has been cancelled
- Nav: icons only on mobile, icon+label on sm+
- Brand: abbreviated "H A" on mobile, full "Hermes Agent" on sm+
- Content: reduced padding on mobile (px-3 vs px-6)
- StatusPage: session cards stack vertically on mobile, truncate
  overflow text, strip model namespace for brevity
- ConfigPage: sidebar becomes horizontal scrollable pills on mobile
  instead of fixed left column, search hidden on mobile
- SessionsPage: title + search stack vertically on mobile, search
  goes full-width
- Card component: add overflow-hidden to prevent content bleed
- Body/root: add overflow-x-hidden to prevent horizontal scroll
- Footer: reduced font sizes on mobile

All changes use Tailwind responsive breakpoints (sm: prefix).
No logic changes — purely layout/CSS adjustments.
2026-04-13 17:16:28 -07:00
Teknium
ac80bd61ad test: add regression tests for custom_providers multi-model dedup and grouping
Tests for salvaged PRs #9233 and #8011.
2026-04-13 16:41:30 -07:00
Ubuntu
ec9bf9e378 feat(model-picker): group custom_providers by name into a single row per provider
The /model picker currently renders one row per ``custom_providers``
entry. When several entries share the same provider name (e.g. four
``ollama-cloud`` entries for ``qwen3-coder``, ``glm-5.1``, ``kimi-k2``,
``minimax-m2.7``), users see four separate "Ollama Cloud" rows in the
picker, which is confusing UX — there is only one Ollama Cloud
provider, so there should be one row containing four models.

This PR groups ``custom_providers`` entries that share the same provider
name into a single picker row while keeping entries with distinct names
as separate rows. So:

* Four entries named ``Ollama Cloud`` → one "Ollama Cloud" row with
  four models inside.
* One entry named ``Ollama Cloud`` and one named ``Moonshot`` → two
  separate rows, one model each.

Implementation
--------------
Replaces the single-pass loop in ``list_authenticated_providers()`` with
a two-pass approach:

1. First pass: build an ``OrderedDict`` keyed by ``custom_provider_slug(name)``,
   accumulating ``models`` per group while preserving discovery order.
2. Second pass: iterate the groups and append one result row per group,
   skipping any slug that already appeared in an earlier provider source
   (the existing ``seen_slugs`` guard).

Insertion order is preserved via ``OrderedDict``, so providers and
their models still appear in the order the user listed them in
``custom_providers``. No new dependencies.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 16:41:30 -07:00
akhater
01f71007d0 fix(config): include model field in custom_providers dedup key
get_compatible_custom_providers() deduplicates by (name, base_url) which
collapses multiple models under the same provider into a single entry.
For example, 7 Ollama Cloud entries with different models become 1.
Adding model to the tuple preserves all entries.
2026-04-13 16:41:30 -07:00
Teknium
32cea0c08d fix: dashboard shows Nous Portal as 'not connected' despite active auth (#9261)
The dashboard device-code flow (_nous_poller in web_server.py) saved
credentials to the credential pool only, while get_nous_auth_status()
only checked the auth store (auth.json). This caused the Keys tab to
show 'not connected' even when the backend was fully authenticated.

Two fixes:
1. get_nous_auth_status() now checks the credential pool first (like
   get_codex_auth_status() already does), then falls back to the auth
   store.
2. _nous_poller now also persists to the auth store after saving to
   the credential pool, matching the CLI flow (_login_nous).

Adds 3 tests covering pool-only, auth-store-fallback, and empty-state
scenarios.
2026-04-13 16:32:11 -07:00
Teknium
8d023e43ed refactor: remove dead code — 1,784 lines across 77 files (#9180)
Deep scan with vulture, pyflakes, and manual cross-referencing identified:
- 41 dead functions/methods (zero callers in production)
- 7 production-dead functions (only test callers, tests deleted)
- 5 dead constants/variables
- ~35 unused imports across agent/, hermes_cli/, tools/, gateway/

Categories of dead code removed:
- Refactoring leftovers: _set_default_model, _setup_copilot_reasoning_selection,
  rebuild_lookups, clear_session_context, get_logs_dir, clear_session
- Unused API surface: search_models_dev, get_pricing, skills_categories,
  get_read_files_summary, clear_read_tracker, menu_labels, get_spinner_list
- Dead compatibility wrappers: schedule_cronjob, list_cronjobs, remove_cronjob
- Stale debug helpers: get_debug_session_info copies in 4 tool files
  (centralized version in debug_helpers.py already exists)
- Dead gateway methods: send_emote, send_notice (matrix), send_reaction
  (bluebubbles), _normalize_inbound_text (feishu), fetch_room_history
  (matrix), _start_typing_indicator (signal), parse_feishu_post_content
- Dead constants: NOUS_API_BASE_URL, SKILLS_TOOL_DESCRIPTION,
  FILE_TOOLS, VALID_ASPECT_RATIOS, MEMORY_DIR
- Unused UI code: _interactive_provider_selection,
  _interactive_model_selection (superseded by prompt_toolkit picker)

Test suite verified: 609 tests covering affected files all pass.
Tests for removed functions deleted. Tests using removed utilities
(clear_read_tracker, MEMORY_DIR) updated to use internal APIs directly.
2026-04-13 16:32:04 -07:00
Teknium
a66fc1365d fix: add files:read to SLACK_BOT_TOKEN description in config.py
Missed in the original PR — the env var description also lists required scopes.
2026-04-13 16:31:38 -07:00
helix4u
448b8bfb7c docs: add slack files:read scope 2026-04-13 16:31:38 -07:00
Teknium
def8b959b8 fix: add contributor audit script + fix missed contributors (#9264)
Three problems fixed:

1. bobashopcashier missing from v0.9.0 contributor list despite
   authoring the gateway drain PR (#7290, salvaged into #7503).
   Their email (kennyx102@gmail.com) was missing from AUTHOR_MAP.

2. release.py only scanned git commit authors, missing Co-authored-by
   trailers. Now parse_coauthors() extracts trailers from commit bodies.

3. No mechanism to detect contributors from salvaged PRs (where original
   author only appears in PR description, not git log).

Changes:
- scripts/release.py: add kennyx102@gmail.com to AUTHOR_MAP, enhance
  get_commits() to parse Co-authored-by trailers, filter AI assistants
  (Claude, Copilot, Cursor Agent) from co-author lists
- scripts/contributor_audit.py: new script that cross-references git
  authors, co-author trailers, and salvaged PR descriptions. Reports
  unknown emails and contributors missing from release notes.
- RELEASE_v0.9.0.md: add bobashopcashier to community contributors

Usage:
  python scripts/contributor_audit.py --since-tag v2026.4.8
  python scripts/contributor_audit.py --since-tag v2026.4.8 --release-file RELEASE_v0.9.0.md
2026-04-13 16:31:27 -07:00
helix4u
f94f53cc22 fix(matrix): disable streaming cursor decoration on Matrix 2026-04-13 16:31:02 -07:00
helix4u
0ffb6f2dae fix(matrix): skip cursor-only stream placeholder messages 2026-04-13 16:31:02 -07:00
Teknium
b27eaaa4db fix: improve ACP type check and restore comment accuracy
- Use isinstance() with try/except import for CopilotACPClient check
  in _to_async_client instead of fragile __class__.__name__ string check
- Restore accurate comment: GPT-5.x models *require* (not 'often require')
  the Responses API on OpenAI/OpenRouter; ACP is the exception, not a
  softening of the requirement
- Add inline comment explaining the ACP exclusion rationale
2026-04-13 16:17:43 -07:00
helix4u
8680f61f8b fix(copilot-acp): keep acp runtime off responses path 2026-04-13 16:17:43 -07:00
Teknium
063244bb16 test: add coverage for plugin context engine init (#9071)
Some checks failed
Docker Build and Publish / build-and-push (pull_request) Has been skipped
Nix / nix (ubuntu-latest) (pull_request) Failing after 3s
Supply Chain Audit / Scan PR for supply chain risks (pull_request) Successful in 47s
Tests / e2e (pull_request) Successful in 2m35s
Docs Site Checks / docs-site-checks (pull_request) Failing after 6m6s
Tests / test (pull_request) Failing after 34m29s
Nix / nix (macos-latest) (pull_request) Has been cancelled
Verify that plugin context engines receive update_model() with correct
context_length during AIAgent init — regression test for the ctx -- bug.
2026-04-13 15:00:57 -07:00
Stephen Schoettler
c763ed5801 fix(agent): resolve context_length for plugin context engines
Plugin context engines loaded via load_context_engine() were never
given context_length, causing the CLI status bar to show "ctx --"
with an empty progress bar. Call update_model() immediately after
loading the plugin engine, mirroring what switch_model() already does.

Fixes NousResearch/hermes-agent#9071

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 15:00:57 -07:00
Teknium
204e9190c4 fix: consolidate provider lists into single CANONICAL_PROVIDERS source of truth (#9237)
Three separate hardcoded provider lists (/model, /provider, hermes model)
diverged over time, causing providers to be missing from some commands.

- Create CANONICAL_PROVIDERS in hermes_cli/models.py as the single source
  of truth for all provider identity, labels, and TUI ordering
- Derive _PROVIDER_LABELS and list_available_providers() from canonical list
- Add step 2b in list_authenticated_providers() to cross-check canonical
  list — catches providers with credentials that weren't found via
  PROVIDER_TO_MODELS_DEV or HERMES_OVERLAYS mappings
- Derive hermes model TUI provider menus from canonical list
- Add deepseek and xai as first-class providers (were missing from TUI)
- Add grok/x-ai/x.ai aliases for xai provider

Fixes: /model command not showing all providers that hermes model shows
2026-04-13 14:59:50 -07:00
Teknium
952a885fbf fix(gateway): /stop no longer resets the session (#9224)
/stop was calling suspend_session() which marked the session for auto-reset
on the next message. This meant users lost their conversation history every
time they stopped a running agent — especially painful for untitled sessions
that can't be resumed by name.

Now /stop just interrupts the agent and cleans the session lock. The session
stays intact so users can continue the conversation.

The suspend behavior was introduced in #7536 to break stuck session resume
loops on gateway restart. That case is already handled by
suspend_recently_active() which runs at gateway startup, so removing it from
/stop doesn't regress the original fix.
2026-04-13 14:59:05 -07:00
SHL0MS
d5fd74cac2 fix(ci): don't fail supply chain scan when PR comment can't be posted on fork PRs (#6681)
The GITHUB_TOKEN for fork PRs is read-only — gh pr comment fails with
'Resource not accessible by integration'. This caused the supply chain
scan to show a red X on every fork PR even when no findings were detected.

The scan itself still runs and the 'Fail on critical findings' step
still exits 1 on real issues. Only the comment posting is gracefully
skipped for fork PRs.

Closes #6679

Co-authored-by: SHL0MS <SHL0MS@users.noreply.github.com>
2026-04-13 13:58:59 -07:00
Teknium
a6f07a6c37 docs: fix hermes web → hermes dashboard in web-dashboard.md (#9207)
The actual CLI command is 'hermes dashboard', not 'hermes web'.
cli-commands.md already had the correct name.
2026-04-13 13:26:21 -07:00
Sabin Iacob
a27b3c8725 add git to the container installed packages (fixes #8439) 2026-04-13 13:08:19 -07:00
Teknium
1af2e18d40 chore: release v0.9.0 (v2026.4.13) (#9182)
The everywhere release — Hermes goes mobile with Termux/Android, adds
iMessage and WeChat, ships Fast Mode for OpenAI and Anthropic,
introduces background process monitoring, launches a local web
dashboard, and delivers the deepest security hardening pass yet
across 16 supported platforms.

487 commits, 269 merged PRs, 167 resolved issues, 24 contributors.
2026-04-13 11:52:09 -07:00
Teknium
0e60a9dc25 fix: add kimi-coding-cn to remaining provider touchpoints
Follow-up for salvaged PR #7637. Adds kimi-coding-cn to:
- model_normalize.py (prefix strip)
- providers.py (models.dev mapping)
- runtime_provider.py (credential resolution)
- setup.py (model list + setup label)
- doctor.py (health check)
- trajectory_compressor.py (URL detection)
- models_dev.py (registry mapping)
- integrations/providers.md (docs)
2026-04-13 11:20:37 -07:00
hcshen0111
2b3aa36242 feat(providers): add kimi-coding-cn provider for mainland China users
Cherry-picked from PR #7637 by hcshen0111.
Adds kimi-coding-cn provider with dedicated KIMI_CN_API_KEY env var
and api.moonshot.cn/v1 endpoint for China-region Moonshot users.
2026-04-13 11:20:37 -07:00
Teknium
ef180880aa fix: guard anthropic_adapter import + use canonical authorize URL
- Wrap module-level import from agent.anthropic_adapter in try/except
  so hermes web still starts if the adapter is unavailable; Phase 2
  PKCE endpoints return 501 in that case.
- Change authorize URL from console.anthropic.com to claude.ai to
  match the canonical adapter code.
2026-04-13 11:18:18 -07:00
kshitijk4poor
247929b0dd feat: dashboard OAuth provider management
Add OAuth provider management to the Hermes dashboard with full
lifecycle support for Anthropic (PKCE), Nous and OpenAI Codex
(device-code) flows.

## Backend (hermes_cli/web_server.py)

- 6 new API endpoints:
  GET /api/providers/oauth — list providers with connection status
  POST /api/providers/oauth/{id}/start — initiate PKCE or device-code
  POST /api/providers/oauth/{id}/submit — exchange PKCE auth code
  GET /api/providers/oauth/{id}/poll/{session} — poll device-code
  DELETE /api/providers/oauth/{id} — disconnect provider
  DELETE /api/providers/oauth/sessions/{id} — cancel pending session
- OAuth constants imported from anthropic_adapter (no duplication)
- Blocking I/O wrapped in run_in_executor for async safety
- In-memory session store with 15-minute TTL and automatic GC
- Auth token required on all mutating endpoints

## Frontend

- OAuthLoginModal — PKCE (paste auth code) and device-code (poll) flows
- OAuthProvidersCard — status, token preview, connect/disconnect actions
- Toast fix: createPortal to document.body for correct z-index
- App.tsx: skip animation key bump on initial mount (prevent double-mount)
- Integrated into the Env/Keys page
2026-04-13 11:18:18 -07:00
yongtenglei
2773b18b56 fix(run_agent): refresh activity during streaming responses
Previously, long-running streamed responses could be incorrectly treated
as idle by the gateway/cron inactivity timeout even while tokens were
actively arriving. The _touch_activity() call (which feeds
get_activity_summary() polled by the external timeout) was either called
only on the first chunk (chat completions) or not at all (Anthropic,
Codex, Codex fallback).

Add _touch_activity() on every chunk/event in all four streaming paths
so the inactivity monitor knows data is still flowing.

Fixes #8760
2026-04-13 10:55:51 -07:00
Teknium
ba50fa3035 docs: fix 30+ inaccuracies across documentation (#9023)
Cross-referenced all docs pages against the actual codebase and fixed:

Reference docs (cli-commands.md, slash-commands.md, profile-commands.md):
- Fix: hermes web -> hermes dashboard (correct subparser name)
- Fix: Wrong provider list (removed deepseek, ai-gateway, opencode-zen,
  opencode-go, alibaba; added gemini)
- Fix: Missing tts in hermes setup section choices
- Add: Missing --image flag for hermes chat
- Add: Missing --component flag for hermes logs
- Add: Missing CLI commands: debug, backup, import
- Fix: /status incorrectly marked as messaging-only (available everywhere)
- Fix: /statusbar moved from Session to Configuration category
- Add: Missing slash commands: /fast, /snapshot, /image, /debug
- Add: Missing /restart from messaging commands table
- Fix: /compress description to match COMMAND_REGISTRY
- Add: --no-alias flag to profile create docs

Configuration docs (configuration.md, environment-variables.md):
- Fix: Vision timeout default 30s -> 120s
- Fix: TTS providers missing minimax and mistral
- Fix: STT providers missing mistral
- Fix: TTS openai base_url shown with wrong default
- Fix: Compression config showing stale summary_model/provider/base_url
  keys (migrated out in config v17) -> target_ratio/protect_last_n

Getting-started docs:
- Fix: Redundant faster-whisper install (already in voice extra)
- Fix: Messaging extra description missing Slack

Developer guide:
- Fix: architecture.md tool count 48 -> 47, toolset count 40 -> 19
- Fix: run_agent.py line count 9,200 -> 10,700
- Fix: cli.py line count 8,500 -> 10,000
- Fix: main.py line count 5,500 -> 6,000
- Fix: gateway/run.py line count 7,500 -> 9,000
- Fix: Browser tools count 11 -> 10
- Fix: Platform adapter count 15 -> 18 (add wecom_callback, api_server)
- Fix: agent-loop.md wrong budget sharing (not shared, independent)
- Fix: agent-loop.md non-existent _get_budget_warning() reference
- Fix: context-compression-and-caching.md non-existent function name
- Fix: toolsets-reference.md safe toolset includes mixture_of_agents (it doesn't)
- Fix: toolsets-reference.md hermes-cli tool count 38 -> 36

Guides:
- Fix: automate-with-cron.md claims daily at 9am is valid (it's not)
- Fix: delegation-patterns.md Max 3 presented as hard cap (configurable)
- Fix: sessions.md group thread key format (shared by default, not per-user)
- Fix: cron-internals.md job ID format and JSON structure
2026-04-13 10:53:10 -07:00
Teknium
4ca6668daf docs: comprehensive update for recent merged PRs (#9019)
Audit and update documentation across 12 files to match changes from
~50 recently merged PRs. Key updates:

Slash commands (slash-commands.md):
- Add 5 missing commands: /snapshot, /fast, /image, /debug, /restart
- Fix /status incorrectly labeled as messaging-only (available in both)
- Add --global flag to /model docs
- Add [focus topic] arg to /compress docs

CLI commands (cli-commands.md):
- Add hermes debug share section with options and examples
- Add hermes backup section with --quick and --label flags
- Add hermes import section

Feature docs:
- TTS: document global tts.speed and per-provider speed for Edge/OpenAI
- Web dashboard: add docs for 5 missing pages (Sessions, Logs,
  Analytics, Cron, Skills) and 15+ API endpoints
- WhatsApp: add streaming, 4K chunking, and markdown formatting docs
- Skills: add GitHub rate-limit/GITHUB_TOKEN troubleshooting tip
- Budget: document CLI notification on iteration budget exhaustion

Config migration (compression.summary_* → auxiliary.compression.*):
- Update configuration.md, environment-variables.md,
  fallback-providers.md, cli.md, and context-compression-and-caching.md
- Replace legacy compression.summary_model/provider/base_url references
  with auxiliary.compression.model/provider/base_url
- Add legacy migration info boxes explaining auto-migration

Minor fixes:
- wecom-callback.md: clarify 'text only' limitation (input only)
- Escape {session_id}/{job_id} in web-dashboard.md headings for MDX
2026-04-13 10:50:59 -07:00
墨綠BG
c449cd1af5 fix(config): restore custom providers after v11→v12 migration
The v11→v12 migration converts custom_providers (list) into providers
(dict), then deletes the list. But all runtime resolvers read from
custom_providers — after migration, named custom endpoints silently stop
resolving and fallback chains fail with AuthError.

Add get_compatible_custom_providers() that reads from both config schemas
(legacy custom_providers list + v12+ providers dict), normalizes entries,
deduplicates, and returns a unified list. Update ALL consumers:

- hermes_cli/runtime_provider.py: _get_named_custom_provider() + key_env
- hermes_cli/auth_commands.py: credential pool provider names
- hermes_cli/main.py: model picker + _model_flow_named_custom()
- agent/auxiliary_client.py: key_env + custom_entry model fallback
- agent/credential_pool.py: _iter_custom_providers()
- cli.py + gateway/run.py: /model switch custom_providers passthrough
- run_agent.py + gateway/run.py: per-model context_length lookup

Also: use config.pop() instead of del for safer migration, fix stale
_config_version assertions in tests, add pool mock to codex test.

Co-authored-by: 墨綠BG <s5460703@gmail.com>
Closes #8776, salvaged from PR #8814
2026-04-13 10:50:52 -07:00
Teknium
0dd26c9495 fix(tests): fix 78 CI test failures and remove dead test (#9036)
Production fixes:
- voice_mode.py: add is_recording property to AudioRecorder (parity with TermuxAudioRecorder)
- cronjob_tools.py: add sms example to deliver description

Test fixes:
- test_real_interrupt_subagent: add missing _execution_thread_id (fixes 19 cascading failures from leaked _build_system_prompt patch)
- test_anthropic_error_handling: add _FakeMessages, override _interruptible_streaming_api_call (6 fixes)
- test_ctx_halving_fix: add missing request_overrides attribute (4 fixes)
- test_context_token_tracking: set _disable_streaming=True for non-streaming test path (4 fixes)
- test_dict_tool_call_args: set _disable_streaming=True (1 fix)
- test_provider_parity: add model='gpt-4o' for AIGateway tests to meet 64K minimum context (4 fixes)
- test_session_race_guard: add user_id to SessionSource (5 fixes)
- test_restart_drain/helpers: add user_id to SessionSource (2 fixes)
- test_telegram_photo_interrupts: add user_id to SessionSource
- test_interrupt: target thread_id for per-thread interrupt system (2 fixes)
- test_zombie_process_cleanup: rewrite with object.__new__ for refactored GatewayRunner.stop() (1 fix)
- test_browser_camofox_state: update config version 15->17 (1 fix)
- test_trajectory_compressor_async: widen lookback window 10->20 for line-shifted AsyncOpenAI (1 fix)
- test_voice_mode: fixed by production is_recording addition (5 fixes)
- test_voice_cli_integration: add _attached_images to CLI stub (2 fixes)
- test_hermes_logging: explicit propagation/level reset for cross-test pollution defense (1 fix)
- test_run_agent: add base_url for OpenRouter detection tests (2 fixes)

Deleted:
- test_inline_think_blocks_reasoning_only_accepted: tested unimplemented inline <think> handling
2026-04-13 10:50:24 -07:00
kimsr96
b909a9efef fix: extend ASCII-locale UnicodeEncodeError recovery to full request payload
The existing ASCII codec handler only sanitized conversation messages,
leaving tool schemas, system prompts, ephemeral prompts, prefill messages,
and HTTP headers as unhandled sources of non-ASCII content. On systems
with LANG=C or non-UTF-8 locale, Unicode symbols in tool descriptions
(e.g. arrows, em-dashes from prompt_builder) and system prompt content
would cause UnicodeEncodeError that fell through to the error path.

Changes:
- Add _sanitize_structure_non_ascii() generic recursive walker for
  nested dict/list payloads
- Add _sanitize_tools_non_ascii() thin wrapper for tool schemas
- Add _force_ascii_payload flag: once ASCII locale is detected, all
  subsequent API calls get proactively sanitized (prevents recurring
  failures from new tool results bringing fresh Unicode each turn)
- Extend the ASCII codec error handler to sanitize: prefill_messages,
  tool schemas (self.tools), system prompt, ephemeral system prompt,
  and default HTTP headers
- Update stale comment that acknowledged the gap

Cherry-picked from PR #8834 (credential pool changes dropped as
separate concern).
2026-04-13 05:16:35 -07:00
Teknium
28a9c43f81 fix: resolve key_env to actual API key value instead of env var name
The cherry-picked code passed the env var NAME (e.g. 'MY_API_KEY') as the
api_key value. The caller's has_usable_secret() check would reject the
var name, so the actual key was never used. Now we os.getenv() the
key_env value to get the real API key before returning it.
2026-04-13 05:16:21 -07:00
Geoff
76eecf3819 fix(model): Support providers: dict for custom endpoints in /model
Two fixes for user-defined providers in config.yaml:

1. list_authenticated_providers() - now includes full models list from
   providers.*.models array, not just default_model. This fixes /model
   showing only one model when multiple are configured.

2. _get_named_custom_provider() - now checks providers: dict (new-style)
   in addition to custom_providers: list (legacy). This fixes credential
   resolution errors when switching models via /model command.

Both changes are backwards compatible with existing custom_providers list format.

Fixes: Only one model appears for custom providers in /model selection
2026-04-13 05:16:21 -07:00
konsisumer
311dac1971 fix(file_tools): block /private/etc writes on macOS symlink bypass
On macOS, /etc is a symlink to /private/etc, so os.path.realpath()
resolves /etc/hosts to /private/etc/hosts. The sensitive path check
only matched /etc/ prefixes against the resolved path, allowing
writes to system files on macOS.

- Add /private/etc/ and /private/var/ to _SENSITIVE_PATH_PREFIXES
- Check both realpath-resolved and normpath-normalized paths
- Add regression tests for macOS symlink bypass

Closes #8734
Co-authored-by: ElhamDevelopmentStudio (PR #8829)
2026-04-13 05:15:05 -07:00
Teknium
587eeb56b9 chore: remove duplicate dead _try_gh_cli_token / _gh_cli_candidates from auth.py
These functions were duplicated between auth.py and copilot_auth.py.
The auth.py copies had zero production callers — only copilot_auth.py's
versions are used. Redirect the test import to the live copy and update
monkeypatch targets accordingly.
2026-04-13 05:12:36 -07:00
HearthCore
2a9e50c104 fix(copilot): resolve GHE token poisoning when GITHUB_TOKEN is set
When GITHUB_TOKEN is present in the environment (e.g. for gh CLI or
GitHub Actions), two issues broke Copilot authentication against
GitHub Enterprise (GHE) instances:

1. The copilot provider had no base_url_env_var, so COPILOT_API_BASE_URL
   was silently ignored — requests always went to public GitHub.

2. `gh auth token` (the CLI fallback) treats GITHUB_TOKEN as an override
   and echoes it back instead of reading from its credential store
   (hosts.yml). This caused the same rejected token to be used even
   after env var priority correctly skipped it.

Fix:
- Add base_url_env_var="COPILOT_API_BASE_URL" to copilot ProviderConfig
- Strip GITHUB_TOKEN/GH_TOKEN from the subprocess env when calling
  `gh auth token` so it reads from hosts.yml
- Pass --hostname from COPILOT_GH_HOST when set so gh returns the
  GHE-specific OAuth token
2026-04-13 05:12:36 -07:00
luyao618
8ec1608642 fix(agent): propagate api_mode to vision provider resolution
resolve_vision_provider_client() computed resolved_api_mode from config
but never passed it to downstream resolve_provider_client() or
_get_cached_client() calls, causing custom providers with
api_mode: anthropic_messages to crash when used for vision tasks.

Also remove the for_vision special case in _normalize_aux_provider()
that incorrectly discarded named custom provider identifiers.

Fixes #8857

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 05:02:54 -07:00
Teknium
e3ffe5b75f fix: remove legacy compression.summary_* config and env var fallbacks (#8992)
Remove the backward-compat code paths that read compression provider/model
settings from legacy config keys and env vars, which caused silent failures
when auto-detection resolved to incompatible backends.

What changed:
- Remove compression.summary_model, summary_provider, summary_base_url from
  DEFAULT_CONFIG and cli.py defaults
- Remove backward-compat block in _resolve_task_provider_model() that read
  from the legacy compression section
- Remove _get_auxiliary_provider() and _get_auxiliary_env_override() helper
  functions (AUXILIARY_*/CONTEXT_* env var readers)
- Remove env var fallback chain for per-task overrides
- Update hermes config show to read from auxiliary.compression
- Add config migration (v16→17) that moves non-empty legacy values to
  auxiliary.compression and strips the old keys
- Update example config and openclaw migration script
- Remove/update tests for deleted code paths

Compression model/provider is now configured exclusively via:
  auxiliary.compression.provider / auxiliary.compression.model

Closes #8923
2026-04-13 04:59:26 -07:00
WorldInnovationsDepartment
c1809e85e7 fix(gateway): handle stale lock files in acquire_scoped_lock
Updated the acquire_scoped_lock function to treat empty or corrupt lock files as stale. This change ensures that if a lock file exists but is invalid, it will be removed to prevent issues with stale locks. Added tests to verify recovery from both empty and corrupt lock files.
2026-04-13 04:59:25 -07:00
Teknium
23f668d66e fix: extract Gemma 4 <thought> reasoning in _extract_reasoning() (#8991)
Add <thought>(.*?)</thought> to inline_patterns so Gemma 4
reasoning content is captured for /reasoning display, not just
stripped from visible output.


Closes #8891

Co-authored-by: RhushabhVaghela <rhushabhvaghela@users.noreply.github.com>
2026-04-13 04:59:06 -07:00
flobo3
d8a521092b fix(weixin): rename send_document parameter to match base class 2026-04-13 04:58:30 -07:00
Teknium
a5bd56eae3 fix: eliminate provider hang dead zones in retry/timeout architecture (#8985)
Three targeted changes to close the gaps between retry layers that
caused users to experience 'No response from provider for 580s' and
'No activity for 15 minutes' despite having 5 layers of retry:

1. Remove non-streaming fallback from streaming path

   Previously, when all 3 stream retries exhausted, the code fell back
   to _interruptible_api_call() which had no stale detection and no
   activity tracking — a black hole that could hang for up to 1800s.
   Now errors propagate to the main retry loop which has richer recovery
   (credential rotation, provider fallback, backoff).

   For 'stream not supported' errors, sets _disable_streaming flag so
   the main retry loop automatically switches to non-streaming on the
   next attempt.

2. Add _touch_activity to recovery dead zones

   The gateway inactivity monitor relies on _touch_activity() to know
   the agent is alive, but activity was never touched during:
   - Stale stream detection/kill cycles (180-300s gaps)
   - Stream retry connection rebuilds
   - Main retry backoff sleeps (up to 120s)
   - Error recovery classification

   Now all these paths touch activity every ~30s, keeping the gateway
   informed during recovery cycles.

3. Add stale-call detector to non-streaming path

   _interruptible_api_call() now has the same stale detection pattern
   as the streaming path: kills hung connections after 300s (default,
   configurable via HERMES_API_CALL_STALE_TIMEOUT), scaled for large
   contexts (450s for 50K+ tokens, 600s for 100K+ tokens), disabled
   for local providers.

   Also touches activity every ~30s during the wait so the gateway
   monitor stays informed.

Env vars:
- HERMES_API_CALL_STALE_TIMEOUT: non-streaming stale timeout (default 300s)
- HERMES_STREAM_STALE_TIMEOUT: unchanged (default 180s)

Before: worst case ~2+ hours of sequential retries with no feedback
After: worst case bounded by gateway inactivity timeout (default 1800s)
with continuous activity reporting
2026-04-13 04:55:20 -07:00
Teknium
acdff020b7 test: add multi-word query tests for truncation match strategy
Tests phrase matching, proximity co-occurrence, and sliding window
coverage maximisation — the three new tiers from the truncation fix.
2026-04-13 04:54:42 -07:00
Al Sayed Hoota
a5bc698b9a fix(session_search): improve truncation to center on actual query matches
Three-tier match strategy for _truncate_around_matches():
1. Full-phrase search (exact query string positions)
2. Proximity co-occurrence (all terms within 200 chars)
3. Individual terms (fallback, preserves existing behavior)

Sliding window picks the start offset covering the most matches.

Moved inline import re to module level.

Co-authored-by: Al Sayed Hoota <78100282+AlsayedHoota@users.noreply.github.com>
2026-04-13 04:54:42 -07:00
landy
dbed40f39b fix: reopen resumed gateway sessions in sqlite 2026-04-13 04:54:07 -07:00
flobo3
d945cf6b1a fix(docker): add .venv to .dockerignore 2026-04-13 04:52:00 -07:00
twilwa
3a64348772 fix(discord): voice session continuity and signal handler thread safety
- Store source metadata on /voice channel join so voice input shares the
  same session as the linked text channel conversation
- Treat voice-linked text channels as free-response (skip @mention and
  auto-thread) while voice is active
- Scope the voice-linked exemption to the exact bound channel, not
  sibling threads
- Guard signal handler registration in start_gateway() for non-main
  threads (prevents RuntimeError when gateway runs in a daemon thread)
- Clean up _voice_sources on leave_voice_channel

Salvaged from PR #3475 by twilwa (Modal runtime portions excluded).
2026-04-13 04:49:21 -07:00
Teknium
381810ad50 feat: fix SQLite safety in hermes backup + add --quick snapshots + /snapshot command (#8971)
Three changes consolidated into the existing backup system:

1. Fix: hermes backup now uses sqlite3.Connection.backup() for .db files
   instead of raw file copy. Raw copy of a WAL-mode database can produce
   a corrupted backup — the backup() API handles this correctly.

2. hermes backup --quick: fast snapshot of just critical state files
   (config.yaml, state.db, .env, auth.json, cron/jobs.json, etc.)
   stored in ~/.hermes/state-snapshots/. Auto-prunes to 20 snapshots.

3. /snapshot slash command (alias /snap): in-session interface for
   quick state snapshots. create/list/restore/prune subcommands.
   Restore by ID or number. Powered by the same backup module.

No new modules — everything lives in hermes_cli/backup.py alongside
the existing full backup/import code.

No hooks in run_agent.py — purely on-demand, zero runtime overhead.

Closes the use case from PRs #8406 and #7813 with ~200 lines of new
logic instead of a 1090-line content-addressed storage engine.
2026-04-13 04:46:13 -07:00
Richard Li
82901695ff feat(wecom): add platform hint for native media sending 2026-04-13 04:46:04 -07:00
Teknium
3365abdddf fix: use correct 'completed' state in status badge map, clean up blank lines
The cron backend uses 'completed' (not 'exhausted') when repeat count
is reached. Also removes extra blank lines from cherry-pick.
2026-04-13 04:45:29 -07:00
jonny
70f490a12a fix(web): CronPage crash when rendering schedule object
The cron API returns schedule as {kind, expr, display} object but
CronPage.tsx rendered it directly as a React child, crashing with
'Objects are not valid as a React child'.

- Update CronJob interface in api.ts to match actual API response
- Use schedule_display (string) instead of schedule (object)
- Use state instead of status for job state
- Use last_error instead of error for error display
2026-04-13 04:45:29 -07:00
Teknium
8dfee98d06 fix: clean up description escaping, add string-data tests
Follow-up for cherry-picked PR #8918.
2026-04-13 04:45:07 -07:00
dippwho
bca22f3090 fix(homeassistant): #8912 resolve XML tool calling loop by casting nested object to JSON string 2026-04-13 04:45:07 -07:00
MaybeRichard
11e2e04667 fix(telegram): pass proxy URL explicitly to HTTPXRequest when proxy env vars are set
When HTTPS_PROXY / HTTP_PROXY / ALL_PROXY env vars are set (or macOS system proxy
is detected), pass the proxy URL explicitly via HTTPXRequest(proxy=proxy_url) instead
of relying on httpx's trust_env mechanism, which is unreliable for HTTP CONNECT
proxies (e.g. Clash / ClashMac in fake-ip mode).

Uses the shared resolve_proxy_url() from base.py (handles env vars + macOS system
proxy detection) instead of duplicating env var reading inline. Consolidates the
proxy_configured boolean into a single proxy_url = resolve_proxy_url() call that
serves as both the gate for skipping fallback-IP transport and the value passed
to HTTPXRequest.

Co-authored-by: Hermes Agent <hermes@nousresearch.com>
Salvaged from PR #8931 by MaybeRichard.
2026-04-13 04:45:05 -07:00
XiaoXiao0221
860489600a fix(cli): sanitize surrogate characters in handle_paste
Prevents UTF-8 encoding crash when pasting text from Word or Google Docs,
which may contain lone surrogate code points (U+D800-U+DFFF).
Reuses existing _sanitize_surrogates() from run_agent module.
2026-04-13 04:42:45 -07:00
Teknium
0998a57007 refactor: remove 5 dead utility functions from utils.py (#8975)
Remove read_json_file, read_jsonl, append_jsonl, env_str, env_lower —
all added in #7917 but never imported anywhere in the codebase. Also
remove unused List and Optional typing imports.

env_int, env_bool, and the other helpers that have real consumers are
kept.
2026-04-13 04:39:59 -07:00
Teknium
cea34dc7ef fix: follow-up for salvaged PR #8939
- Move test file to tests/hermes_cli/ (consistent with test layout)
- Remove unused imports (os, pytest) from test file
- Update _sanitize_env_lines docstring: now used on read + write paths
2026-04-13 04:35:37 -07:00
Mil Wang (from Dev Box)
e469f3f3db fix: sanitize .env before loading to prevent token duplication (#8908)
When .env files become corrupted (e.g. concatenated KEY=VALUE pairs on
a single line due to concurrent writes or encoding issues), both
python-dotenv and load_env() would parse the entire concatenated string
as a single value. This caused bot tokens to appear duplicated up to 8×,
triggering InvalidToken errors from the Telegram API.

Root cause: _sanitize_env_lines() — which correctly splits concatenated
lines — was only called during save_env_value() writes, not during reads.

Fix:
- load_env() now calls _sanitize_env_lines() before parsing
- env_loader.load_hermes_dotenv() sanitizes the .env file on disk
  before python-dotenv reads it, so os.getenv() also returns clean values
- Added tests reproducing the exact corruption pattern from #8908

Closes #8908
2026-04-13 04:35:37 -07:00
ismell0992-afk
e77f135ed8 fix(cli): narrow Nous Hermes non-agentic warning to actual hermes-3/-4 models
The startup warning that Nous Research Hermes 3 & 4 models are not agentic
fired on any model whose name contained "hermes" anywhere, via a plain
substring check. That false-positived on unrelated local Modelfiles such
as `hermes-brain:qwen3-14b-ctx16k` — a tool-capable Qwen3 wrapper that
happens to live under a custom "hermes" tag namespace — making the warning
noise for legitimate setups.

Replace the substring check with a narrow regex anchored on `^`, `/`, or
`:` boundaries that only matches the real Hermes-3 / Hermes-4 chat family
(e.g. `NousResearch/Hermes-3-Llama-3.1-70B`, `hermes-4-405b`,
`openrouter/hermes3:70b`). Consolidate into a single helper
`is_nous_hermes_non_agentic()` in `hermes_cli.model_switch` so the CLI
and the canonical check don't drift, and route the duplicate inline site
in `cli.HermesCLI._print_warnings()` through the helper.

Add a parametrized test covering positive matches (real Hermes-3/-4
names) and a broad set of negatives (custom Modelfiles, Qwen/Claude/GPT,
older Nous-Hermes-2 families, bare "hermes", empty string, and the
"brain-hermes-3-impostor" boundary case).
2026-04-13 04:33:52 -07:00
ismell0992-afk
3e99964789 fix(agent): prefer Ollama Modelfile num_ctx over GGUF training max
_query_local_context_length was checking model_info.context_length
(the GGUF training max) before num_ctx (the Modelfile runtime override),
inverse to query_ollama_num_ctx. The two helpers therefore disagreed on
the same model:

  hermes-brain:qwen3-14b-ctx32k     # Modelfile: num_ctx 32768
  underlying qwen3:14b GGUF         # qwen3.context_length: 40960

query_ollama_num_ctx correctly returned 32768 (the value Ollama will
actually allocate KV cache for). _query_local_context_length returned
40960, which let ContextCompressor grow conversations past 32768 before
triggering compression — at which point Ollama silently truncated the
prefix, corrupting context.

Swap the order so num_ctx is checked first, matching query_ollama_num_ctx.
Adds a parametrized test that seeds both values and asserts num_ctx wins.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 04:24:07 -07:00
Teknium
39b83f3443 fix: remove sandbox language from tool descriptions
The terminal and execute_code tool schemas unconditionally mentioned
'cloud sandboxes' in their descriptions sent to the model. This caused
agents running on local backends to believe they were in a sandboxed
environment, refusing networking tasks and other operations. Worse,
agents sometimes saved this false belief to persistent memory, making
it persist across sessions.

Reported by multiple users (XLion, 林泽).
2026-04-13 04:23:27 -07:00
Teknium
67fece1176 feat(cli): show notification when iteration budget is reached
Displays a dim warning after the response panel when the agent hit
its max iterations, so the user knows the response may be incomplete.
2026-04-13 03:40:47 -07:00
Teknium
934318ba3a fix: budget-exhausted conversations now get a summary instead of empty response
The post-loop grace call mechanism was broken: it injected a user
message and set _budget_grace_call=True, but could never re-enter the
while loop (already exited).  Worse, the flag blocked the fallback
_handle_max_iterations from running, so final_response stayed None.

Users saw empty/no response when the agent hit max iterations.

Fix: remove the dead grace block and let _handle_max_iterations handle
it directly — it already injects a summary request and makes one extra
toolless API call.
2026-04-13 03:36:20 -07:00
Teknium
3804556cd9 fix: restore clarify toolset row removed in cherry-pick 2026-04-13 02:49:11 -07:00
Haoqing Wang
8e0ae66520 fix(skills): correct TTS/STT providers, add missing platforms/commands in hermes-agent skill
Fixes verified via 5-container parallel testing against v0.8.0 codebase.

Critical fixes:
- TTS providers: replace nonexistent kokoro/fish with actual minimax/mistral/neutts
- STT providers: add missing mistral (Voxtral Transcribe)
- Testing section: remove `source venv/bin/activate` (no venv dir in project)

Expanded coverage:
- Provider table: 13 → 22 entries (add Gemini, xAI, Xiaomi, Qwen OAuth, MiniMax CN, etc.)
- Platform list: add BlueBubbles (iMessage) and Weixin (WeChat), clarify Open WebUI
- Slash commands: add 14 undocumented commands (/approve, /deny, /branch, /fast, etc.)
- Toolsets: add 4 missing (messaging, search, todo, rl)
- Troubleshooting: expand from 6 to 10 sections with practical deployment fixes
  (Copilot OAuth 403, gateway linger, WSL2 systemd, Discord intents, etc.)

Minor fixes:
- agent/ directory description expanded
- delegation config keys completed
- /restart noted as gateway-only
- hermes honcho noted as plugin-dependent
2026-04-13 02:49:11 -07:00
Teknium
397eae5d93 fix: recover partial streamed content on connection failure
When streaming fails after partial content delivery (e.g. OpenRouter
timeout kills connection mid-response), the stub response now carries
the accumulated streamed text instead of content=None.

Two fixes:
1. The partial-stream stub response includes recovered content from
   _current_streamed_assistant_text — the text that was already
   delivered to the user via stream callbacks before the connection
   died.

2. The empty response recovery chain now checks for partial stream
   content BEFORE falling back to _last_content_with_tools (prior
   turn content) or wasting API calls on retries. This prevents:
   - Showing wrong content from a prior turn
   - Burning 3+ unnecessary retry API calls
   - Falling through to '(empty)' when the user already saw content

The root cause: OpenRouter has a ~125s inactivity timeout. When
Anthropic's SSE stream goes silent during extended reasoning, the
proxy kills the connection. The model's text was already partially
streamed but the stub discarded it, triggering the empty recovery
chain which would show stale prior-turn content or waste retries.
2026-04-13 02:12:01 -07:00
Teknium
35b11f48a5 docs: add web dashboard documentation (#8864)
- New docs page: user-guide/features/web-dashboard.md covering
  quick start, prerequisites, all three pages (Status, Config, API Keys),
  the /reload slash command, REST API endpoints, CORS config, and
  development workflow
- Added 'Management' category in sidebar for web-dashboard
- Added 'hermes web' to CLI commands reference with options table
- Added '/reload' to slash commands reference (both CLI and gateway tables)
2026-04-13 01:15:27 -07:00
Ubuntu
73ed09e145 fix(gateway): keep venv python symlink unresolved when remapping paths
_remap_path_for_user was calling .resolve() on the Python path, which
followed venv/bin/python into the base interpreter. On uv-managed venvs
this swaps the systemd ExecStart to a bare Python that has none of the
venv's site-packages, so the service crashes on first import. Classical
python -m venv installs were unaffected by accident: the resolved target
/usr/bin/python3.x lives outside $HOME so the path-remap branch was
skipped and the system Python's packages silently worked.

Remove .resolve() calls on both current_home and the path; use
.expanduser() for lexical tilde expansion only. The function does
lexical prefix substitution, which is all it needs to do for its
actual purpose (remapping /root/.hermes -> /home/<user>/.hermes when
installing system services as root for a different user).

Repro: on a uv-managed venv install, `sudo hermes gateway install
--system` writes ExecStart=.../uv/python/cpython-3.11.15-.../bin/python3.11
instead of .../hermes-agent/venv/bin/python, and the service crashes on
ModuleNotFoundError: yaml.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:49:22 -07:00
Teknium
964ef681cf fix(gateway): improve /restart response with fallback instructions 2026-04-12 22:34:23 -07:00
Teknium
276d20e62c fix(gateway): /restart uses service restart under systemd instead of detached subprocess
The detached bash subprocess spawned by /restart gets killed by
systemd's KillMode=mixed cgroup cleanup, leaving the gateway dead.

Under systemd (detected via INVOCATION_ID env var), /restart now uses
via_service=True which exits with code 75 — RestartForceExitStatus=75
in the unit file makes systemd auto-restart the service. The detached
subprocess approach is preserved as fallback for non-systemd
environments (Docker, tmux, foreground mode).
2026-04-12 22:32:19 -07:00
Teknium
e2a9b5369f feat: web UI dashboard for managing Hermes Agent (#8756)
* feat: web UI dashboard for managing Hermes Agent (salvage of #8204/#7621)

Adds an embedded web UI dashboard accessible via `hermes web`:
- Status page: agent version, active sessions, gateway status, connected platforms
- Config editor: schema-driven form with tabbed categories, import/export, reset
- API Keys page: set, clear, and view redacted values with category grouping
- Sessions, Skills, Cron, Logs, and Analytics pages

Backend:
- hermes_cli/web_server.py: FastAPI server with REST endpoints
- hermes_cli/config.py: reload_env() utility for hot-reloading .env
- hermes_cli/main.py: `hermes web` subcommand (--port, --host, --no-open)
- cli.py / commands.py: /reload slash command for .env hot-reload
- pyproject.toml: [web] optional dependency extra (fastapi + uvicorn)
- Both update paths (git + zip) auto-build web frontend when npm available

Frontend:
- Vite + React + TypeScript + Tailwind v4 SPA in web/
- shadcn/ui-style components, Nous design language
- Auto-refresh status page, toast notifications, masked password inputs

Security:
- Path traversal guard (resolve().is_relative_to()) on SPA file serving
- CORS localhost-only via allow_origin_regex
- Generic error messages (no internal leak), SessionDB handles closed properly

Tests: 47 tests covering reload_env, redact_key, API endpoints, schema
generation, path traversal, category merging, internal key stripping,
and full config round-trip.

Original work by @austinpickett (PR #1813), salvaged by @kshitijk4poor
(PR #7621#8204), re-salvaged onto current main with stale-branch
regressions removed.

* fix(web): clean up status page cards, always rebuild on `hermes web`

- Remove config version migration alert banner from status page
- Remove config version card (internal noise, not surfaced in TUI)
- Reorder status cards: Agent → Gateway → Active Sessions (3-col grid)
- `hermes web` now always rebuilds from source before serving,
  preventing stale web_dist when editing frontend files

* feat(web): full-text search across session messages

- Add GET /api/sessions/search endpoint backed by FTS5
- Auto-append prefix wildcards so partial words match (e.g. 'nimb' → 'nimby')
- Debounced search (300ms) with spinner in the search icon slot
- Search results show FTS5 snippets with highlighted match delimiters
- Expanding a search hit auto-scrolls to the first matching message
- Matching messages get a warning ring + 'match' badge
- Inline term highlighting within Markdown (text, bold, italic, headings, lists)
- Clear button (x) on search input for quick reset

---------

Co-authored-by: emozilla <emozilla@nousresearch.com>
2026-04-12 22:26:28 -07:00
Dusk1e
c052cf0eea fix(security): validate domain/service params in ha_call_service to prevent path traversal 2026-04-12 22:26:15 -07:00
Teknium
8a64f3e368 feat(gateway): notify /restart requester when gateway comes back online
When a user sends /restart, the gateway now persists their routing info
(platform, chat_id, thread_id) to .restart_notify.json. After the new
gateway process starts and adapters connect, it reads the file, sends a
'Gateway restarted successfully' message to that specific chat, and
cleans up the file.

This follows the same pattern as _send_update_notification (used by
/update). Thread IDs are preserved so the notification lands in the
correct Telegram topic or Discord thread.

Previously, after /restart the user had no feedback that the gateway was
back — they had to send a message to find out. Now they get a proactive
notification and know their session continues.
2026-04-12 22:23:48 -07:00
Teknium
b22663ea69 docs: restore Orchestra Research attribution in research-paper-writing skill (#8800)
PR #4654 replaced ml-paper-writing with research-paper-writing, preserving
the writing philosophy and reference files but dropping the dedicated
'Sources Behind This Guidance' attribution table from the SKILL.md body.

Re-adds:
- The researcher attribution table (Nanda, Farquhar, Gopen & Swan, Lipton,
  Steinhardt, Perez, Karpathy) with affiliations and links to SKILL.md
- Orchestra Research credit as original compiler of the writing philosophy
- 'Origin & Attribution' section in sources.md documenting the full chain:
  Nanda blog → Orchestra skill → teknium integration → SHL0MS expansion
2026-04-12 22:03:18 -07:00
Teknium
83ca0844f7 fix: preserve dots in model names for OpenCode Zen and ZAI providers (#8794)
OpenCode Zen was in _DOT_TO_HYPHEN_PROVIDERS, causing all dotted model
names (minimax-m2.5-free, gpt-5.4, glm-5.1) to be mangled. The fix:

Layer 1 (model_normalize.py): Remove opencode-zen from the blanket
dot-to-hyphen set. Add an explicit block that preserves dots for
non-Claude models while keeping Claude hyphenated (Zen's Claude
endpoint uses anthropic_messages mode which expects hyphens).

Layer 2 (run_agent.py _anthropic_preserve_dots): Add opencode-zen and
zai to the provider allowlist. Broaden URL check from opencode.ai/zen/go
to opencode.ai/zen/ to cover both Go and Zen endpoints. Add bigmodel.cn
for ZAI URL detection.

Also adds glm-5.1 to ZAI model lists in models.py and setup.py.

Closes #7710

Salvaged from contributions by:
- konsisumer (PR #7739, #7719)
- DomGrieco (PR #8708)
- Esashiero (PR #7296)
- sharziki (PR #7497)
- XiaoYingGee (PR #8750)
- APTX4869-maker (PR #8752)
- kagura-agent (PR #7157)
2026-04-12 21:22:59 -07:00
Teknium
a0cd2c5338 fix(gateway): verbose tool progress no longer truncates args when tool_preview_length is 0 (#8735)
When tool_preview_length is 0 (default for platforms without a tier
default, like Session), verbose mode was truncating args JSON to 200
characters.  Since the user explicitly opted into verbose mode, they
expect full tool call detail — the 200-char cap defeated the purpose.

Now: tool_preview_length=0 means no truncation in verbose mode.
Positive values still cap as before.  Platform message-length limits
handle overflow naturally.
2026-04-12 20:05:12 -07:00
Teknium
3636f64540 fix: resolve npm audit vulnerabilities in browser tools and whatsapp bridge (#8745)
* fix(telegram): use UTF-16 code units for message length splitting

Port from nearai/ironclaw#2304: Telegram's 4096 character limit is
measured in UTF-16 code units, not Unicode codepoints. Characters
outside the Basic Multilingual Plane (emoji like 😀, CJK Extension B,
musical symbols) are surrogate pairs: 1 Python char but 2 UTF-16 units.

Previously, truncate_message() used Python's len() which counts
codepoints. This could produce chunks exceeding Telegram's actual limit
when messages contain many astral-plane characters.

Changes:
- Add utf16_len() helper and _prefix_within_utf16_limit() for
  UTF-16-aware string measurement and truncation
- Add _custom_unit_to_cp() binary-search helper that maps a custom-unit
  budget to the largest safe codepoint slice position
- Update truncate_message() to accept optional len_fn parameter
- Telegram adapter now passes len_fn=utf16_len when splitting messages
- Fix fallback truncation in Telegram error handler to use
  _prefix_within_utf16_limit instead of codepoint slicing
- Update send_message_tool.py to use utf16_len for Telegram platform
- Add comprehensive tests: utf16_len, _prefix_within_utf16_limit,
  truncate_message with len_fn (emoji splitting, content preservation,
  code block handling)
- Update mock lambdas in reply_mode tests to accept **kw for len_fn

* fix: resolve npm audit vulnerabilities in browser tools and whatsapp bridge

Browser tools (agent-browser):
- Override lodash to 4.18.1 (fixes prototype pollution CVEs in transitive
  dep via node-simctl → @appium/logger). Not reachable in Hermes's code
  path but cleans the audit report.
- basic-ftp and brace-expansion updated via npm audit fix.

WhatsApp bridge:
- file-type updated (fixes infinite loop in ASF parser + ZIP bomb DoS)
- music-metadata updated (fixes infinite loop in ASF parser)
- path-to-regexp updated (fixes ReDoS, mitigated by localhost binding)

Both components now report 0 npm vulnerabilities.

Ref: https://gist.github.com/jacklevin74/b41b710d3e20ba78fb7e2d42e2b83819
2026-04-12 19:38:20 -07:00
Teknium
15b1a3aa69 fix: improve WhatsApp UX — chunking, formatting, streaming (#8723)
Three changes that address the poor WhatsApp experience reported by users:

1. Reclassify WhatsApp from TIER_LOW to TIER_MEDIUM in display_config.py
   — enables streaming and tool progress via the existing Baileys /edit
   bridge endpoint. Users now see progressive responses instead of
   minutes of silence followed by a wall of text.

2. Lower MAX_MESSAGE_LENGTH from 65536 to 4096 and add proper chunking
   — send() now calls format_message() and truncate_message() before
   sending, then loops through chunks with a small delay between them.
   The base class truncate_message() already handles code block boundary
   detection (closes/reopens fences at chunk boundaries). reply_to is
   only set on the first chunk.

3. Override format_message() with WhatsApp-specific markdown conversion
   — converts **bold** to *bold*, ~~strike~~ to ~strike~, headers to
   bold text, and [links](url) to text (url). Code blocks and inline
   code are protected from conversion via placeholder substitution.

Together these fix the two user complaints:
- 'sends the whole code all the time' → now chunked at 4K with proper
  formatting
- 'terminal gets interrupted and gets cooked' → streaming + tool progress
  give visual feedback so users don't accidentally interrupt with
  follow-up messages
2026-04-12 19:20:13 -07:00
Teknium
5fae356a85 fix: show full last assistant response when resuming a session (#8724)
When resuming a session with --resume or -c, the last assistant response
was truncated to 200 chars / 3 lines just like older messages in the recap.
This forced users to waste tokens re-asking for the response.

Now the last assistant message in the recap is shown in full with non-dim
styling, so users can see exactly where they left off. Earlier messages
remain truncated for compact display.

Changes:
- Track un-truncated text for the last assistant entry during collection
- Replace last entry with full text after history trimming
- Render last assistant entry with bold (non-dim) styling
- Update existing truncation tests to use multi-message histories
- Add new tests for full last response display (char + multiline)
2026-04-12 19:07:14 -07:00
Teknium
9e992df8ae fix(telegram): use UTF-16 code units for message length splitting (#8725)
Port from nearai/ironclaw#2304: Telegram's 4096 character limit is
measured in UTF-16 code units, not Unicode codepoints. Characters
outside the Basic Multilingual Plane (emoji like 😀, CJK Extension B,
musical symbols) are surrogate pairs: 1 Python char but 2 UTF-16 units.

Previously, truncate_message() used Python's len() which counts
codepoints. This could produce chunks exceeding Telegram's actual limit
when messages contain many astral-plane characters.

Changes:
- Add utf16_len() helper and _prefix_within_utf16_limit() for
  UTF-16-aware string measurement and truncation
- Add _custom_unit_to_cp() binary-search helper that maps a custom-unit
  budget to the largest safe codepoint slice position
- Update truncate_message() to accept optional len_fn parameter
- Telegram adapter now passes len_fn=utf16_len when splitting messages
- Fix fallback truncation in Telegram error handler to use
  _prefix_within_utf16_limit instead of codepoint slicing
- Update send_message_tool.py to use utf16_len for Telegram platform
- Add comprehensive tests: utf16_len, _prefix_within_utf16_limit,
  truncate_message with len_fn (emoji splitting, content preservation,
  code block handling)
- Update mock lambdas in reply_mode tests to accept **kw for len_fn
2026-04-12 19:06:20 -07:00
Teknium
3cd6cbee5f feat: add /debug slash command for all platforms
Adds /debug as a slash command available in CLI, Telegram, Discord,
Slack, and all other gateway platforms. Uploads debug report + full
logs to paste services and returns shareable URLs.

- commands.py: CommandDef in Info category (no cli_only/gateway_only)
- gateway/run.py: async handler with run_in_executor for blocking I/O
- cli.py: dispatch in process_command to run_debug_share
2026-04-12 18:08:45 -07:00
Teknium
f724079d3b fix(gateway): reject known-weak placeholder credentials at startup
Port from openclaw/openclaw#64586: users who copy .env.example without
changing placeholder values now get a clear error at startup instead of
a confusing auth failure from the platform API. Also rejects placeholder
API_SERVER_KEY when binding to a network-accessible address.

Cherry-picked from PR #8677.
2026-04-12 18:05:41 -07:00
Teknium
c7d8d109ff fix(matrix): trust m.mentions.user_ids as authoritative mention signal
Port from openclaw/openclaw#64796: Per MSC3952 / Matrix v1.7, the
m.mentions.user_ids field is the authoritative mention signal. Clients
that populate m.mentions but don't duplicate @bot in the body text
were being silently dropped when MATRIX_REQUIRE_MENTION=true.

Cherry-picked from PR #8673.
2026-04-12 18:05:41 -07:00
Teknium
88a12af58c feat: add hermes debug share — upload debug report to pastebin (#8681)
* feat: add `hermes debug share` — upload debug report to pastebin

Adds a new `hermes debug share` command that collects system info
(via hermes dump), recent logs (agent.log, errors.log, gateway.log),
and uploads the combined report to a paste service (paste.rs primary,
dpaste.com fallback). Returns a shareable URL for support.

Options:
  --lines N    Number of log lines per file (default: 200)
  --expire N   Paste expiry in days (default: 7, dpaste.com only)
  --local      Print report locally without uploading

Files:
  hermes_cli/debug.py           - New module: paste upload + report collection
  hermes_cli/main.py            - Wire cmd_debug + argparse subparser
  tests/hermes_cli/test_debug.py - 19 tests covering upload, collection, CLI

* feat: upload full agent.log and gateway.log as separate pastes

hermes debug share now uploads up to 3 pastes:
  1. Summary report (system info + log tails) — always
  2. Full agent.log (last ~500KB) — if file exists
  3. Full gateway.log (last ~500KB) — if file exists

Each paste uploads independently; log upload failures are noted
but don't block the main report. Output shows all links aligned:

  Report     https://paste.rs/abc
  agent.log  https://paste.rs/def
  gateway.log https://paste.rs/ghi

Also adds _read_full_log() with size-capped tail reading to stay
within paste service limits (~512KB per file).

* feat: prepend hermes dump to each log paste for self-contained context

Each paste (agent.log, gateway.log) now starts with the hermes dump
output so clicking any single link gives full system context without
needing to cross-reference the summary report.

Refactored dump capture into _capture_dump() — called once and
reused across the summary report and each log paste.

* fix: fall back to .1 rotated log when primary log is missing or empty

When gateway.log (or agent.log) doesn't exist or is empty, the debug
share now checks for the .1 rotation file. This is common — the
gateway rotates logs and the primary file may not exist yet.

Extracted _resolve_log_path() to centralize the fallback logic for
both _read_log_tail() and _read_full_log().

* chore: remove unused display_hermes_home import
2026-04-12 18:05:14 -07:00
Teknium
bcad679799 fix(api_server): normalize array-based content parts in chat completions
Some OpenAI-compatible clients (Open WebUI, LobeChat, etc.) send
message content as an array of typed parts instead of a plain string:

    [{"type": "text", "text": "hello"}]

The agent pipeline expects strings, so these array payloads caused
silent failures or empty messages.

Add _normalize_chat_content() with defensive limits (recursion depth,
list size, output length) and apply it to both the Chat Completions
and Responses API endpoints. The Responses path had inline
normalization that only handled input_text/output_text — the shared
function also handles the standard 'text' type.

Salvaged from PR #7980 (ikelvingo) — only the content normalization;
the SSE and Weixin changes in that PR were regressions and are not
included.

Co-authored-by: ikelvingo <ikelvingo@users.noreply.github.com>
2026-04-12 18:03:16 -07:00
AaronWong1999
e8385f6f89 docs: add HermesClaw to community ecosystem
Adds a one-line entry for HermesClaw (community WeChat bridge) to the Community section. It lets users run Hermes Agent and OpenClaw on the same WeChat account.
2026-04-12 18:03:16 -07:00
Sicheng Li
ea2829ab43 fix(weixin,wecom,matrix): respect system proxy via aiohttp trust_env
aiohttp.ClientSession defaults to trust_env=False, ignoring HTTP_PROXY/
HTTPS_PROXY env vars. This causes QR login and all API calls to fail for
users behind a proxy (e.g. Clash in fake-ip mode), which is common in
China where Weixin and WeCom are primarily used.

Added trust_env=True to all aiohttp.ClientSession instantiations that
connect to external hosts (weixin: 3 places, wecom: 1, matrix: 1).
WhatsApp sessions are excluded as they only connect to localhost.

httpx-based adapters (dingtalk, signal, wecom_callback) are unaffected
as httpx defaults to trust_env=True.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 18:03:16 -07:00
Teknium
bc4e2744c3 test: add tests for compression config_context_length passthrough
- Test that auxiliary.compression.context_length from config is forwarded
  to get_model_context_length (positive case)
- Test that invalid/non-integer config values are silently ignored
- Fix _make_agent() to set config=None (cherry-picked code reads self.config)
2026-04-12 17:52:34 -07:00
ygd58
4a9c356559 fix(compression): pass configured context_length to feasibility check
_check_compression_model_feasibility() called get_model_context_length()
without passing config_context_length, so custom endpoints that do not
support /models API queries always fell through to the 128K default,
ignoring auxiliary.compression.context_length in config.yaml.

Fix: read auxiliary.compression.context_length from config and pass it
as config_context_length (highest-priority hint) so the user-configured
value is always respected regardless of API availability.

Fixes #8499
2026-04-12 17:52:34 -07:00
Teknium
0d0d27d45e test(tts): add speed config tests for Edge, OpenAI, and MiniMax
12 tests covering:
- Provider-specific speed overrides global speed
- Global speed used as fallback
- Default (no speed) preserves existing behavior
- Edge SSML rate string conversion (positive/negative)
- OpenAI speed clamping to 0.25-4.0 range
2026-04-12 16:46:18 -07:00
0xbyt4
8ec0656f53 feat(tts): add speed support for Edge TTS and OpenAI TTS
Read tts.speed (global) or tts.<provider>.speed (provider-specific) from
config. Provider-specific takes precedence over global.

- Edge TTS: converts speed float to SSML prosody rate string
- OpenAI TTS: passes speed param clamped to 0.25-4.0
- MiniMax: wired into global tts.speed fallback for consistency

Co-authored-by: 0xbyt4 <0xbyt4@users.noreply.github.com>
2026-04-12 16:46:18 -07:00
Teknium
651419b014 fix: make mimo-v2-pro the default model for Nous portal users
Users who set up Nous auth without explicitly selecting a model via
`hermes model` were silently falling back to anthropic/claude-opus-4.6
(the first entry in _PROVIDER_MODELS['nous']), causing unexpected
charges on their Nous plan. Move xiaomi/mimo-v2-pro to the first
position so unconfigured users default to a free model instead.
2026-04-12 16:44:03 -07:00
Teknium
a266238e1e fix(weixin): streaming cursor, media uploads, markdown links, blank messages (#8665)
Four fixes for the Weixin/WeChat adapter, synthesized from the best
aspects of community PRs #8407, #8521, #8360, #7695, #8308, #8525,
#7531, #8144, #8251.

1. Streaming cursor (▉) stuck permanently — WeChat doesn't support
   message editing, so the cursor appended during streaming can never
   be removed.  Add SUPPORTS_MESSAGE_EDITING = False to WeixinAdapter
   and check it in gateway/run.py to use an empty cursor for non-edit
   platforms.  (Fixes #8307, #8326)

2. Media upload failures — two bugs in _send_file():
   a) upload_full_url path used PUT (404 on WeChat CDN); now uses POST.
   b) aes_key was base64(raw_bytes) but the iLink API expects
      base64(hex_string); images showed as grey boxes.  (Fixes #8352, #7529)
   Also: unified both upload paths into _upload_ciphertext(), preferring
   upload_full_url.  Added send_video/send_voice methods and voice_item
   media builder for audio/.silk files.  Added video_md5 field.

3. Markdown links stripped — WeChat can't render [text](url), so
   format_message() now converts them to 'text (url)' plaintext.
   Code blocks are preserved.  (Fixes #7617)

4. Blank message prevention — three guards:
   a) _split_text_for_weixin_delivery('') returns [] not ['']
   b) send() filters empty/whitespace chunks before _send_text_chunk
   c) _send_message() raises ValueError for empty text as safety net

Community credit: joei4cm (#8407), lyonDan (#8521), SKFDJKLDG (#8360),
tomqiaozc (#7695), joshleeeeee (#8308), luoxiao6645(#8525),
longsizhuo (#7531), Astral-Yang (#8144), QingWei-Li (#8251).
2026-04-12 16:43:25 -07:00
Teknium
c83674dd77 fix: unify OpenClaw detection, add isatty guard, fix print_warning import
Combines detection from both PRs into _detect_openclaw_processes():
- Cross-platform process scan (pgrep/tasklist/PowerShell) from PR #8102
- systemd service check from PR #8555
- Returns list[str] with details about what's found

Fixes in cleanup warning (from PR #8555):
- print_warning -> print_error/print_info (print_warning not in import chain)
- Added isatty() guard for non-interactive sessions
- Removed duplicate _check_openclaw_running() in favor of shared function

Updated all tests to match new API.
2026-04-12 16:40:37 -07:00
Serhat Dolmac
76f7411fca fix(claw): warn and prompt if OpenClaw is still running before archival (fixes #8502) 2026-04-12 16:40:37 -07:00
dirtyfancy
9fb36738a7 fix(claw): address Copilot review on Windows detection and non-interactive prompt
- Use PowerShell to inspect node.exe command lines on Windows,
  since tasklist output does not include them.
- Also check for dedicated openclaw.exe/clawd.exe processes.
- Skip the interactive prompt in non-interactive sessions so the
  preview-only behavior is preserved.
- Update tests accordingly.

Relates to #7907
2026-04-12 16:40:37 -07:00
dirtyfancy
5af9614f6d fix(claw): warn if OpenClaw is running before migration
Add _is_openclaw_running() and _warn_if_openclaw_running() to detect
OpenClaw processes (via pgrep/tasklist) before hermes claw migrate.
Warns the user that messaging platforms only allow one active session
per bot token, and lets them cancel or continue.

Fixes #7907
2026-04-12 16:40:37 -07:00
Teknium
76019320fb feat(skills): centralized skills index — eliminate GitHub API calls for search/install
Add a CI-built skills index served from the docs site. The index is
crawled daily by GitHub Actions, resolves all GitHub paths upfront, and
is cached locally by the client. When the index is available:

- Search uses the cached index (0 GitHub API calls, was 23+)
- Install uses resolved paths from index (6 API calls for file
  downloads only, was 31-45 for discovery + downloads)

Total: 68 → 6 GitHub API calls for a typical search + install flow.
Unauthenticated users (60 req/hr) can now search and install without
hitting rate limits.

Components:
- scripts/build_skills_index.py: Crawl all sources (skills.sh, GitHub
  taps, official, clawhub, lobehub), batch-resolve GitHub paths via
  tree API, output JSON index
- tools/skills_hub.py: HermesIndexSource class — search/fetch/inspect
  backed by the index, with lazy GitHubSource for file downloads
- parallel_search_sources() skips external API sources when index is
  available (0 GitHub calls for search)
- .github/workflows/skills-index.yml: twice-daily CI build + deploy
- .github/workflows/deploy-site.yml: also builds index during docs deploy

Graceful degradation: when the index is unavailable (first run, network
down, stale), all methods return empty/None and downstream sources
handle the request via direct API as before.
2026-04-12 16:39:04 -07:00
Teknium
7e0e5ea03b fix(skills): cache GitHub repo trees to avoid rate-limit exhaustion on install
Skills.sh installs hit the GitHub API 45 times per install because the
same repo tree was fetched 6 times redundantly. Combined with search
(23 API calls), this totals 68 — exceeding the unauthenticated rate
limit of 60 req/hr, causing 'Could not fetch' errors for users without
a GITHUB_TOKEN.

Changes:
- Add _get_repo_tree() cache to GitHubSource — repo info + recursive
  tree fetched once per repo per source instance, eliminating 10
  redundant API calls (6 tree + 4 candidate 404s)
- _download_directory_via_tree returns {} (not None) when cached tree
  shows path doesn't exist, skipping unnecessary Contents API fallback
- _check_rate_limit_response() detects exhausted quota and sets
  is_rate_limited flag
- do_install() shows actionable hint when rate limited: set
  GITHUB_TOKEN or install gh CLI

Before: 45 API calls per install (68 total with search)
After:  31 API calls per install (54 total with search — under 60/hr)

Reported by community user from Vietnam (no GitHub auth configured).
2026-04-12 16:39:04 -07:00
Teknium
4c6ebd077e chore: sync uv.lock with matrix extra deps (aiosqlite, asyncpg) (#8661)
These were already declared in pyproject.toml but missing from the lockfile.
2026-04-12 16:38:15 -07:00
alt-glitch
5e1197a42e fix(gateway): harden Docker/container gateway pathway
Centralize container detection in hermes_constants.is_container() with
process-lifetime caching, matching existing is_wsl()/is_termux() patterns.
Dedup _is_inside_container() in config.py to delegate to the new function.

Add _run_systemctl() wrapper that converts FileNotFoundError to RuntimeError
for defense-in-depth — all 10 bare subprocess.run(_systemctl_cmd(...)) call
sites now route through it.

Make supports_systemd_services() return False in containers and when
systemctl binary is absent (shutil.which check).

Add Docker-specific guidance in gateway_command() for install/uninstall/start
subcommands — exit 0 with helpful instructions instead of crashing.

Make 'hermes status' show 'Manager: docker (foreground)' and 'hermes dump'
show 'running (docker, pid N)' inside containers.

Fix setup_gateway() to use supports_systemd instead of _is_linux for all
systemd-related branches, and show Docker restart policy instructions in
containers.

Replace inline /.dockerenv check in voice_mode.py with is_container().

Fixes #7420

Co-authored-by: teknium1 <teknium1@users.noreply.github.com>
2026-04-12 16:36:11 -07:00
sprmn24
18ab5c99d1 fix(backup): correct marker filenames in _validate_backup_zip
The backup validation checked for 'hermes_state.db' and 'memory_store.db'
as telltale markers of a valid Hermes backup zip. Neither name exists in a
real Hermes installation — the actual database file is 'state.db'
(hermes_state.py: DEFAULT_DB_PATH = get_hermes_home() / 'state.db').

A fresh Hermes installation produces:
  ~/.hermes/state.db        (actual name)
  ~/.hermes/config.yaml
  ~/.hermes/.env

Because the marker set never matched 'state.db', a backup zip containing
only 'state.db' plus 'config.yaml' would fail validation with:
  'zip does not appear to be a Hermes backup'
and the import would exit with sys.exit(1), silently rejecting a valid backup.

Fix: replace the wrong marker names with the correct filename.

Adds TestValidateBackupZip with three cases:
- state.db is accepted as a valid marker
- old wrong names (hermes_state.db, memory_store.db) alone are rejected
- config.yaml continues to pass (existing behaviour preserved)
2026-04-12 16:35:56 -07:00
Teknium
d6785dc4d4 fix: empty response recovery for reasoning models (mimo, qwen, GLM) (#8609)
Three fixes for the (empty) response bug affecting open reasoning models:

1. Allow retries after prefill exhaustion — models like mimo-v2-pro always
   populate reasoning fields via OpenRouter, so the old 'not _has_structured'
   guard on the retry path blocked retries for EVERY reasoning model after
   the 2 prefill attempts.  Now: 2 prefills + 3 retries = 6 total attempts
   before (empty).

2. Reset prefill/retry counters on tool-call recovery — the counters
   accumulated across the entire conversation, never resetting during
   tool-calling turns.  A model cycling empty→prefill→tools→empty burned
   both prefill attempts and the third empty got zero recovery.  Now
   counters reset when prefill succeeds with tool calls.

3. Strip think blocks before _truly_empty check — inline <think> content
   made the string non-empty, skipping both retry paths.

Reported by users on Telegram with xiaomi/mimo-v2-pro and qwen3.5 models.
Reproduced: qwen3.5-9b emits tool calls as XML in reasoning field instead
of proper function calls, causing content=None + tool_calls=None + reasoning
with embedded <tool_call> XML.  Prefill recovery works but counter
accumulation caused permanent (empty) in long sessions.
2026-04-12 15:38:11 -07:00
Teknium
a4593f8b21 feat: make gateway 'still working' notification interval configurable (#8572)
Add agent.gateway_notify_interval config option (default 600s).
Set to 0 to disable periodic 'still working' notifications.
Bridged to HERMES_AGENT_NOTIFY_INTERVAL env var (same pattern as
gateway_timeout and gateway_timeout_warning).

The inactivity warning (gateway_timeout_warning) was already
configurable; this makes the wall-clock ping configurable too.
2026-04-12 13:06:34 -07:00
Teknium
1179918746 fix: salvage follow-ups for Feishu QR onboarding (#7706)
- Remove duplicate _setup_feishu() definition (old 3-line version left
  behind by cherry-pick — Python picked the new one but dead code
  remained)
- Remove misleading 'Disable direct messages' DM option — the Feishu
  adapter has no DM policy mechanism, so 'disable' produced identical
  env vars to 'pairing'. Users who chose 'disable' would still see
  pairing prompts. Reduced to 3 options: pairing, allow-all, allowlist.
- Fix test_probe_returns_bot_info_on_success and
  test_probe_returns_none_on_failure: patch FEISHU_AVAILABLE=True so
  probe_bot() takes the SDK path when lark_oapi is not installed
2026-04-12 13:05:56 -07:00
Shuo
d7785f4d5b feat(feishu): add scan-to-create onboarding for Feishu / Lark
Add a QR-based onboarding flow to `hermes gateway setup` for Feishu / Lark.
Users scan a QR code with their phone and the platform creates a fully
configured bot application automatically — matching the existing WeChat
QR login experience.

Setup flow:
- Choose between QR scan-to-create (new app) or manual credential input (existing app)
- Connection mode selection (WebSocket / Webhook)
- DM security policy (pairing / open / allowlist / disabled)
- Group chat policy (open with @mention / disabled)

Implementation:
- Onboard functions (init/begin/poll/QR/probe) in gateway/platforms/feishu.py
- _setup_feishu() in hermes_cli/gateway.py with manual fallback
- probe_bot uses lark_oapi SDK when available, raw HTTP fallback otherwise
- qr_register() catches expected errors (network/protocol), propagates bugs
- Poll handles HTTP 4xx JSON responses and feishu/lark domain auto-detection

Tests:
- 25 tests for onboard module (registration, QR, probe, contract, negative paths)
- 16 tests for setup flow (credentials, connection mode, DM policy, group policy,
  adapter integration verifying env vars produce valid FeishuAdapterSettings)

Change-Id: I720591ee84755f32dda95fbac4b26dc82cbcf823
2026-04-12 13:05:56 -07:00
Teknium
a9ebb331bc fix: contextual error diagnostics for invalid API responses (#8565)
Previously, all invalid API responses (choices=None) were diagnosed
as 'fast response often indicates rate limiting' regardless of actual
response time or error code. A 738s Cloudflare 524 timeout was labeled
as 'fast response' and 'possible rate limit'.

Now extracts the error code from response.error and classifies:
- 524: upstream provider timed out (Cloudflare)
- 504: upstream gateway timeout
- 429: rate limited by upstream provider
- 500/502: upstream server error
- 503/529: upstream provider overloaded
- Other codes: shown with code number
- No code + <10s: likely rate limited (timing heuristic)
- No code + >60s: likely upstream timeout
- No code + 10-60s: neutral response time

All downstream messages (retry status, final error, interrupt message)
now use the classified hint instead of generic rate-limit language.

Reported by community member Lumen Radley (MiMo provider timeouts).
2026-04-12 13:00:07 -07:00
Teknium
400fe9b2a1 fix: add <thought> stripping to auxiliary_client + tests
auxiliary_client.py had its own regex mirroring _strip_think_blocks
but was missing the <thought> variant. Also adds test coverage for
<thought> paired and orphaned tags.
2026-04-12 12:44:49 -07:00
Chen Chia Yang
326d5febe5 fix: also strip <thought> tags during streaming in cli.py 2026-04-12 12:44:49 -07:00
Chen Chia Yang
a372c14fc5 fix: strip <thought> tags from Gemma 4 responses in _strip_think_blocks
Gemma 4 (26B/31B) uses <thought>...</thought> to wrap its reasoning
output. This tag was not included in the existing list of reasoning tag
variants stripped by _strip_think_blocks(), causing raw thinking blocks
to leak into the visible response.

Added a new re.sub() line for <thought> and extended the cleanup regex
to include 'thought' alongside the existing variants.

Fixes #6148
2026-04-12 12:44:49 -07:00
Teknium
f295b17d92 fix: make agent_thread daemon to prevent orphan CLI processes on tab close (#8557)
When a user closes a terminal tab, SIGHUP exits the main thread but
the non-daemon agent_thread kept the entire Python process alive —
stuck in the API call loop with no interrupt signal. Over many
conversations, these orphan processes accumulate and cause massive
swap usage (reported: 77GB on a 32GB M1 Pro).

Changes:
- Make agent_thread daemon=True so the process exits when the main
  thread finishes its cleanup. Under normal operation this changes
  nothing — the main thread already waits on agent_thread.is_alive().
- Interrupt the agent in the finally/exit path so the daemon thread
  stops making API calls promptly rather than being killed mid-flight.
2026-04-12 12:38:55 -07:00
Teknium
06290f6a2f fix: handle broken stdin in prompt_toolkit startup (#6393) (#8560)
On macOS with uv-managed Python, stdin (fd 0) can be invalid or
unregisterable with the asyncio selector, causing:

  KeyError: '0 is not registered'

during prompt_toolkit's app.run() → asyncio.run() → _add_reader(0).

Three-layer fix:
1. Pre-flight fstat(0) check before app.run() — detects broken stdin
   early and prints actionable guidance instead of a raw traceback.
2. Catch KeyError/OSError around app.run() as fallback for edge cases
   that slip past the fstat guard.
3. Extend asyncio exception handler to suppress selector registration
   KeyErrors in async callbacks.

Fixes #6393
2026-04-12 12:38:03 -07:00
Teknium
06a17c57ae fix: improve profile creation UX — seed SOUL.md + credential warning (#8553)
Fresh profiles (created without --clone) now:
- Auto-seed a default SOUL.md immediately, so users have a file to
  customize right away instead of discovering it only after first use
- Print a clear warning that the profile has no API keys and will
  inherit from the shell environment unless configured separately
- Show the SOUL.md path for personality customization

Previously, fresh profiles started with no SOUL.md (only seeded on
first use via ensure_hermes_home), no mention of credential isolation,
and no guidance about customizing personality. Users reported confusion
about profiles using the wrong model/plan tokens and SOUL.md not
being read — both traced to operational gaps in the creation UX.

Closes #8093 (investigated: code correctly loads SOUL.md from profile
HERMES_HOME; issue was operational, not a code bug).
2026-04-12 12:22:34 -07:00
Teknium
4eecaf06e4 fix: prevent duplicate update prompt spam in gateway watcher (#8343)
The _watch_update_progress() poll loop never deleted .update_prompt.json
after forwarding the prompt to the user, causing the same prompt to be
re-sent every poll cycle (2s). Two fixes:

1. Delete .update_prompt.json after forwarding — the update process only
   polls for .update_response, it doesn't need the prompt file to persist.
2. Guard re-sends with _update_prompt_pending check — belt-and-suspenders
   to prevent duplicates even under race conditions.

Add regression test asserting the prompt is sent exactly once.
2026-04-12 04:52:59 -07:00
Teknium
7a67b13506 fix: title_generator no longer logs as 'compression' task
Changed task='compression' to task='title_generation' so auto-title
calls don't pollute logs with false compression alarms.
2026-04-12 04:17:18 -07:00
Teknium
45e60904c6 fix: fall back to provider's default model when model config is empty (#8303)
When a user configures a provider (e.g. `hermes auth add openai-codex`)
but never selects a model via `hermes model`, the gateway and CLI would
pass an empty model string to the API, causing:
  'Codex Responses request model must be a non-empty string'

Now both gateway (_resolve_session_agent_runtime) and CLI
(_ensure_runtime_credentials) detect an empty model and fill it from
the provider's first catalog entry in _PROVIDER_MODELS. This covers
all providers that have a static model list (openai-codex, anthropic,
gemini, copilot, etc.).

The fix is conservative: it only triggers when model is truly empty
and a known provider was resolved. Explicit model choices are never
overridden.
2026-04-12 03:53:30 -07:00
Teknium
17c72f176d fix: make skill loading instructions more aggressive in system prompt (#8286)
The previous wording ('If one clearly matches') set too high a threshold,
and 'If none match, proceed normally' was an easy escape hatch for lazy
models. Now:

- Lowered threshold: 'matches or is even partially relevant'
- Added MUST directive and 'err on the side of loading' guidance
- Replaced permissive closer with 'only proceed without if genuinely none
  are relevant'

This should reduce cases where the agent skips loading relevant skills
unless explicitly forced.
2026-04-12 03:03:16 -07:00
Teknium
b6b6b02f0f fix: prevent unwanted session auto-reset after graceful gateway restarts (#8299)
When the gateway shuts down gracefully (hermes update, gateway restart,
/restart), it now writes a .clean_shutdown marker file. On the next
startup, if this marker exists, suspend_recently_active() is skipped
and the marker is cleaned up.

Previously, suspend_recently_active() fired on EVERY startup —
including planned restarts from hermes update or hermes gateway restart.
This caused users to lose their conversation history unexpectedly: the
session would be marked as suspended, and the next message would
trigger an auto-reset with a notification the user never asked for.

The original purpose of suspend_recently_active() is crash recovery —
preventing stuck sessions that were mid-processing when the gateway
died unexpectedly. Graceful shutdowns already drain active agents via
_drain_active_agents(), so there is no stuck-session risk. After a
crash (no marker written), suspension still fires as before.

Fixes the scenario where a user asks the agent to run hermes update,
the gateway restarts, and the user's next message gets an unwanted
'Session automatically reset' notification with their history cleared.
2026-04-12 03:03:07 -07:00
Teknium
56e3ee2440 fix: write update exit code before gateway restart (cgroup kill race) (#8288)
When /update runs via Telegram, hermes update --gateway is spawned inside
the gateway's systemd cgroup.  The update process itself calls
systemctl restart hermes-gateway, which tears down the cgroup with
KillMode=mixed — SIGKILL to all remaining processes.  The wrapping bash
shell is killed before it can execute the exit-code epilogue, so
.update_exit_code is never created.  The new gateway's update watcher
then polls for 30 minutes and sends a spurious timeout message.

Fix: write .update_exit_code from Python inside cmd_update() immediately
after the git pull + pip install succeed ("Update complete!"), before
attempting the gateway restart.  The shell epilogue still writes it too
(idempotent overwrite), but now the marker exists even when the process
is killed mid-restart.
2026-04-12 02:33:21 -07:00
Teknium
b321330362 feat: add WSL environment hint to system prompt (#8285)
When running inside WSL (Windows Subsystem for Linux), inject a hint into
the system prompt explaining that the Windows host filesystem is mounted
at /mnt/c/, /mnt/d/, etc. This lets the agent naturally translate Windows
paths (Desktop, Documents) to their /mnt/ equivalents without the user
needing to configure anything.

Uses the existing is_wsl() detection from hermes_constants (cached,
checks /proc/version for 'microsoft'). Adds build_environment_hints()
in prompt_builder.py — extensible for Termux, Docker, etc. later.

Closes the UX gap where WSL users had to manually explain path
translation to the agent every session.
2026-04-12 02:26:28 -07:00
Teknium
dd5b1063d0 fix: register MATRIX_RECOVERY_KEY env var + document migration path
Follow-up for cherry-picked PR #8272:
- Add MATRIX_RECOVERY_KEY to module docstring header in matrix.py
- Register in OPTIONAL_ENV_VARS (config.py) with password=True, advanced=True
- Add to _NON_SETUP_ENV_VARS set
- Document cross-signing verification in matrix.md E2EE section
- Update migration guide with recovery key step (step 3)
- Add to environment-variables.md reference
2026-04-12 02:18:03 -07:00
elkimek
b9af4955b9 fix(matrix): restore verify_with_recovery_key after device key rotation
After the PgCryptoStore migration in v0.8.0, the verify_with_recovery_key
call that previously ran after share_keys() was dropped. On any rotation
that uploads fresh device keys (fresh crypto.db, server had stale keys
from a prior install, etc.), the new device keys carry no valid self-
signing signature because the bot has no access to the self-signing
private key.

Peers like Element then refuse to share Megolm sessions with the
rotated device, so the bot silently stops decrypting incoming messages.

This restores the recovery-key bootstrap: on startup, if
MATRIX_RECOVERY_KEY is set, import the cross-signing private keys from
SSSS and sign_own_device(), producing a valid signature server-side.

Idempotent and gated on MATRIX_RECOVERY_KEY — no behavior change for
users who don't configure a recovery key.

Verified end-to-end by deleting crypto.db and restarting: the bot
rotates device identity keys, re-uploads, self-signs via recovery key,
and decrypts+replies to fresh messages from a paired Element client.
2026-04-12 02:18:03 -07:00
Ben Barclay
b0d65c333a Merge pull request #8279 from NousResearch/chore/simplify-docker-tags
chore: simplify Docker image tags
2026-04-12 19:09:05 +10:00
Ben
00adbd0de0 chore: simplify Docker image tags
- Main branch push: only push :latest (remove SHA tag)
- Release push: only push release tag name (remove :latest and SHA tag)
2026-04-12 19:08:16 +10:00
Teknium
95fa78eb6c fix: write refreshed Codex tokens back to ~/.codex/auth.json (#8277)
OpenAI OAuth refresh tokens are single-use and rotate on every refresh.
When Hermes refreshes a Codex token, it consumed the old refresh_token
but never wrote the new pair back to ~/.codex/auth.json. This caused
Codex CLI and VS Code to fail with 'refresh_token_reused' on their
next refresh attempt.

This mirrors the existing Anthropic write-back pattern where refreshed
tokens are written to ~/.claude/.credentials.json via
_write_claude_code_credentials().

Changes:
- Add _write_codex_cli_tokens() in hermes_cli/auth.py (parallel to
  _write_claude_code_credentials in anthropic_adapter.py)
- Call it from _refresh_codex_auth_tokens() (non-pool refresh path)
- Call it from credential_pool._refresh_entry() (pool happy path + retry)
- Add tests for the new write-back behavior
- Update existing test docstring to clarify _save_codex_tokens vs
  _write_codex_cli_tokens separation

Fixes refresh token conflict reported by @ec12edfae2cb221
2026-04-12 02:05:20 -07:00
Teknium
6d05e3d56f fix(gateway): evict cached agent on /model switch + add diagnostic logging (#8276)
After /model switches the model (both picker and text paths), the cached
agent's config signature becomes stale — the agent was updated in-place
via switch_model() but the cache tuple's signature was never refreshed.
The next turn *should* detect the signature mismatch and create a fresh
agent, but this relies on the new model's signature differing from the
old one in _agent_config_signature().

Evicting the cached agent explicitly after storing the session override
is more defensive — the next turn is guaranteed to create a fresh agent
from the override without depending on signature mismatch detection.

Also adds debug logging at three key decision points so we can trace
exactly what happens when /model + /retry interact:
- _resolve_session_agent_runtime: which override path is taken (fast
  with api_key vs fallback), or why no override was found
- _run_agent.run_sync: final resolved model/provider before agent
  creation

Reported: /model switch to xiaomi/mimo-v2-pro followed by /retry still
used the old model (glm-5.1).
2026-04-12 01:58:17 -07:00
Teknium
4aa534eae5 fix(gateway): peek at pending message during interrupt instead of consuming it
The monitor_for_interrupt() and backup interrupt checks were calling
get_pending_message() which pops the message from the adapter's queue.
This created a race condition: if the agent finished naturally before
checking _interrupt_requested, the pending message was permanently lost.

Timeline of the race:
1. Agent near completion, user sends message
2. Level 1 guard stores message in adapter._pending_messages, sets event
3. monitor_for_interrupt() detects event, POPS message, calls agent.interrupt()
4. Agent's run_conversation() was already returning (interrupted=False)
5. Post-run dequeue finds nothing (monitor already consumed it)
6. result.get('interrupted') is False so interrupt_message fallback doesn't fire
7. User message permanently lost — agent finishes without processing it

Fix: change all three interrupt detection sites (primary monitor + two
backup checks) from get_pending_message() (pop) to
_pending_messages.get() (peek). The message stays in the adapter's queue
until _dequeue_pending_event() consumes it in the post-run handler,
which runs regardless of whether the agent was interrupted or finished
naturally.

Reported by @_SushantSays — intermittent message loss during long
terminal command execution, persisting after the previous fix (73f970fa)
which addressed monitor task death but not this consumption race.
2026-04-12 01:57:34 -07:00
Teknium
ae6820a45a fix(setup): validate base URL input in hermes model flow (#8264)
Reject non-URL values (e.g. shell commands typed by mistake) in the
base URL prompt during provider setup. Previously any string was saved
as-is to .env, breaking connectivity when the garbage value was used
as the API endpoint.

Adds http:// / https:// prefix check with a clear error message.
The custom-endpoint flow already had this validation (line 1620);
this brings the generic API-key provider flow to parity.

Triggered by a user support case where 'nano ~/.hermes/.env' was
accidentally entered as GLM_BASE_URL during Z.AI setup.
2026-04-12 01:51:57 -07:00
Teknium
a1220977d3 fix: make skill loading instructions more aggressive in system prompt (#8209)
The previous wording ('If one clearly matches') set too high a threshold,
and 'If none match, proceed normally' was an easy escape hatch for lazy
models. Now:

- Lowered threshold: 'matches or is even partially relevant'
- Added MUST directive and 'err on the side of loading' guidance
- Replaced permissive closer with 'only proceed without if genuinely none
  are relevant'

This should reduce cases where the agent skips loading relevant skills
unless explicitly forced.
2026-04-12 01:46:34 -07:00
Teknium
078dba015d fix: three provider-related bugs (#8161, #8181, #8147) (#8243)
- Add openai/openai-codex -> openai mapping to PROVIDER_TO_MODELS_DEV
  so context-length lookups use models.dev data instead of 128k fallback.
  Fixes #8161.

- Set api_mode from custom_providers entry when switching via hermes model,
  and clear stale api_mode when the entry has none. Also extract api_mode
  in _named_custom_provider_map(). Fixes #8181.

- Convert OpenAI image_url content blocks to Anthropic image blocks when
  the endpoint is Anthropic-compatible (MiniMax, MiniMax-CN, or any URL
  containing /anthropic). Fixes #8147.
2026-04-12 01:44:18 -07:00
Harish Kukreja
b1f13a8c5f fix(agent): route compression aux through live session runtime 2026-04-12 01:34:52 -07:00
Teknium
c52f6348b6 fix: list all available toolsets in delegate_task schema description (#8231)
* fix: list all available toolsets in delegate_task schema description

The delegate_task tool's toolsets parameter description only mentioned
'terminal', 'file', and 'web' as examples. Models (especially smaller
ones like Gemma) would substitute 'web' for 'browser' because they
didn't know 'browser' was a valid option.

Now dynamically builds the toolset list from the TOOLSETS dict at import
time, excluding blocked, composite, and platform-specific toolsets.
Auto-updates when new toolsets are added.

Reported by jeffutter on Discord.

* chore: exclude moa and rl from delegate_task toolset list
2026-04-12 00:54:35 -07:00
Teknium
3162472674 feat(tips): add 69 deeper hidden-gem tips (279 total) (#8237)
Add lesser-known power-user tips covering:
- BOOT.md gateway startup automation
- Cron script attachment for data collection pipelines
- Prefill messages for few-shot priming
- Focus topic compression (/compress <topic>)
- Terminal exit code annotations and auto-retry
- Automatic sudo password piping
- execute_code built-in helpers (json_parse, shell_quote, retry)
- File loop detection and staleness warnings
- MCP sampling and dynamic tool discovery
- Delegation heartbeat and ACP child agents (Claude Code)
- 402 auto-fallback in auxiliary client
- Container mode, HERMES_HOME_MODE, subprocess HOME isolation
- Ctrl+C 5-tier priority system
- Browser CDP URL override and stealth mode
- Skills quarantine, audit log, and well-known protocol
- Per-platform display overrides, human delay mode
- And many more deep-cut features
2026-04-12 00:54:07 -07:00
Teknium
8b9d22a74b revert: keep debian:13.4 full image instead of slim
The slim image drops packages that may be needed at runtime.
Keep the full Debian base for compatibility.
2026-04-12 00:53:16 -07:00
m0n5t3r
fee0e0d35e fix(docker): run as non-root user, use virtualenv (salvage #5811)
- Add gosu for runtime privilege dropping from root to hermes user
- Support HERMES_UID/HERMES_GID env vars for host mount permission matching
- Switch to debian:13.4-slim base image
- Use uv venv instead of pip install --break-system-packages
- Pin uv and gosu multi-stage images with SHA256 digests
- Set PLAYWRIGHT_BROWSERS_PATH to /opt/hermes/.playwright so build-time
  chromium install survives the /opt/data volume mount
- Keep procps for container debugging

Based on work by m0n5t3r in PR #5811. Stripped to hardening-only
changes (non-root, virtualenv, slim base); matrix deps, fonts, xvfb,
and entrypoint playwright download deferred to follow-up.
2026-04-12 00:53:16 -07:00
bravohenry
81ac62c0e9 fix(weixin): split chatty short replies into separate bubbles, keep structured content together
Add content-aware splitting to compact mode: short chat-like exchanges
(2-6 short lines without headings/lists/quotes) get separate message
bubbles for a natural chat feel, while structured content (tables,
headings with body, numbered lists) stays in a single message.

Cherry-picked from PR #7587 by bravohenry, adapted to the compact/legacy
split_per_line architecture from #7903.
2026-04-12 00:38:07 -07:00
Teknium
f53a5a7fe1 fix: suppress duplicate completion notifications when agent already consumed output via wait/poll/log (#8228)
When the agent calls process(action='wait') or process(action='poll')
and gets the exited status, the completion_queue notification is
redundant — the agent already has the output from the tool return.
Previously, the drain loops in CLI and gateway would still inject
the [SYSTEM: Background process completed] message, causing the
agent to receive the same information twice.

Fix: track session IDs in _completion_consumed set when wait/poll/log
returns an exited process. Drain loops in cli.py and gateway watcher
skip completion events for consumed sessions. Watch pattern events
are never suppressed (they have independent semantics).

Adds 4 tests covering wait/poll/log marking and running-process
negative case.
2026-04-12 00:36:22 -07:00
Teknium
fdf55e0fe9 feat(cli): show random tip on new session start (#8225)
Add a 'tip of the day' feature that displays a random one-liner about
Hermes Agent features on every new session — CLI startup, /clear, /new,
and gateway /new across all messaging platforms.

- New hermes_cli/tips.py module with 210 curated tips covering slash
  commands, keybindings, CLI flags, config options, tools, gateway
  platforms, profiles, sessions, memory, skills, cron, voice, security,
  and more
- CLI: tips display in skin-aware dim gold color after the welcome line
- Gateway: tips append to the /new and /reset response on all platforms
- Fully wrapped in try/except — tips are non-critical and never break
  startup or reset

Display format (CLI):
  ✦ Tip: /btw <question> asks a quick side question without tools or history.

Display format (gateway):
   Session reset! Starting fresh.
  ✦ Tip: hermes -c resumes your most recent CLI session.
2026-04-12 00:34:01 -07:00
opriz
36f57dbc51 fix(migration): don't auto-archive OpenClaw source directory
Remove auto-archival from hermes claw migrate — not its
responsibility (hermes claw cleanup is still there for that).

Skip MESSAGING_CWD when it points inside the OpenClaw source
directory, which was the actual root cause of agent confusion
after migration. Use Path.is_relative_to() for robust path
containment check.

Salvaged from PR #8192 by opriz.
Co-authored-by: opriz <opriz@users.noreply.github.com>
2026-04-12 00:33:54 -07:00
Teknium
1871227198 feat: rebrand OpenClaw references to Hermes during migration
- Add rebrand_text() that replaces OpenClaw, Open Claw, Open-Claw,
  ClawdBot, and MoltBot with Hermes (case-insensitive, word-boundary)
- Apply rebranding to memory entries (MEMORY.md, USER.md, daily memory)
- Apply rebranding to SOUL.md and workspace instructions via new
  transform parameter on copy_file()
- Fix moldbot -> moltbot typo across codebase (claw.py, migration
  script, docs, tests)
- Add unit tests for rebrand_text and integration tests for memory
  and soul migration rebranding
2026-04-12 00:33:54 -07:00
Teknium
eb2a49f95a fix: openai-codex and anthropic not appearing in /model picker for external credentials (#8224)
Users whose credentials exist only in external files — OpenAI Codex
OAuth tokens in ~/.codex/auth.json or Anthropic Claude Code credentials
in ~/.claude/.credentials.json — would not see those providers in the
/model picker, even though hermes auth and hermes model detected them.

Root cause: list_authenticated_providers() only checked the raw Hermes
auth store and env vars. External credential file fallbacks (Codex CLI
import, Claude Code file discovery) were never triggered.

Fix (three parts):
1. _seed_from_singletons() in credential_pool.py: openai-codex now
   imports from ~/.codex/auth.json when the Hermes auth store is empty,
   mirroring resolve_codex_runtime_credentials().
2. list_authenticated_providers() in model_switch.py: auth store + pool
   checks now run for ALL providers (not just OAuth auth_type), catching
   providers like anthropic that support both API key and OAuth.
3. list_authenticated_providers(): direct check for anthropic external
   credential files (Claude Code, Hermes PKCE). The credential pool
   intentionally gates anthropic behind is_provider_explicitly_configured()
   to prevent auxiliary tasks from silently consuming tokens. The /model
   picker bypasses this gate since it is discovery-oriented.
2026-04-12 00:33:42 -07:00
Teknium
73f970fa4d fix: make gateway interrupt detection resilient to monitor task failures
The interrupt mechanism for regular text messages (non-commands) during
active agent runs relied on a single async polling task
(monitor_for_interrupt) with no error handling. If this task died
silently due to an unhandled exception, stale adapter reference after
reconnect, or any other failure, user messages sent during agent
execution would be queued but never trigger an actual interrupt — the
agent would continue running until it finished naturally, then process
the queued message.

Three improvements:

1. Error handling in monitor_for_interrupt(): wrap the polling body in
   try/except so transient errors are logged and retried instead of
   silently killing the task.

2. Fresh adapter reference on each poll iteration: re-resolve
   self.adapters.get(source.platform) every 200ms instead of capturing
   the adapter once at task creation time. This prevents stale
   references after adapter reconnects.

3. Backup interrupt check in the inactivity poll loop: both the
   unlimited and timeout-enabled paths now check for pending interrupts
   every 5 seconds (the existing poll interval). Uses a shared
   _interrupt_detected asyncio.Event to avoid double-firing when the
   primary monitor already handled the interrupt. Logs at INFO level
   with monitor task state for debugging.
2026-04-12 00:25:05 -07:00
Teknium
4cadfef8e3 fix(cli): restore stacked tool progress scrollback in TUI (#8201)
The TUI transition (4970705, f83e86d) replaced stacked per-tool history
lines with a single live-updating spinner widget. While the spinner
provides a nice live timer, it removed the scrollback history that
users relied on to see what the agent did during a session.

This restores stacked tool progress lines in 'all' and 'new' modes by
printing persistent scrollback lines via _cprint() when tools complete,
in addition to the existing live spinner display.

Behavior per mode:
- off: no scrollback lines, no spinner (unchanged)
- new: scrollback line on completion, skipping consecutive same-tool repeats
- all: scrollback line on every tool completion
- verbose: no scrollback (run_agent.py handles verbose output directly)

Implementation:
- Store function_args from tool.started events in _pending_tool_info
- On tool.completed, pop stored args and format via get_cute_tool_message()
- FIFO queue per function_name handles concurrent tool execution
- 'new' mode tracks _last_scrollback_tool for dedup
- State cleared at end of agent run

Reported by community user Mr.D — the stacked history provides
transparency into what the agent is doing, which builds trust.

Addresses user report from Discord about lost tool call visibility.
2026-04-11 23:22:34 -07:00
Teknium
8e00b3a69e fix(cron): steer model away from explicit deliver targets that lose topic context (#8187)
Rewrite the cronjob tool's 'deliver' parameter description to strongly
guide models toward omitting the parameter (which auto-detects origin
including thread/topic). The previous description listed all platform
names equally, inviting models to construct explicit targets like
'telegram:<chat_id>' which silently drops the thread_id.

New description:
- Leads with 'Omit this parameter' as the recommended path
- Explicitly warns that platform:chat_id without :thread_id loses topics
- Removes the long flat list of platform names that invited construction

Also adds diagnostic logging at two key points:
- _origin_from_env(): logs when thread_id is captured during job creation
- _deliver_result(): warns when origin has thread_id but delivery target
  lost it; logs at debug when delivering to a specific thread

Helps diagnose user-reported issue where cron responses from Telegram
topics are delivered to the main chat instead of the originating topic.
2026-04-11 23:20:39 -07:00
Teknium
1ca9b19750 feat: add network.force_ipv4 config to fix IPv6 timeout issues (#8196)
On servers with broken or unreachable IPv6, Python's socket.getaddrinfo
returns AAAA records first. urllib/httpx/requests all try IPv6 connections
first and hang for the full TCP timeout before falling back to IPv4. This
affects web_extract, web_search, the OpenAI SDK, and all HTTP tools.

Adds network.force_ipv4 config option (default: false) that monkey-patches
socket.getaddrinfo to resolve as AF_INET when the caller didn't specify a
family. Falls back to full resolution if no A record exists, so pure-IPv6
hosts still work.

Applied early at all three entry points (CLI, gateway, cron scheduler)
before any HTTP clients are created.

Reported by user @29n — Chinese Ubuntu server with unreachable IPv6 causing
timeouts on lobste.rs and other IPv6-enabled sites while Google/GitHub
worked fine (IPv4-only resolution).
2026-04-11 23:12:11 -07:00
Teknium
1cec910b6a fix: improve context compaction to prevent model answering stale questions (#8107)
After compression, models (especially Kimi 2.5) would sometimes respond
to questions from the summary instead of the latest user message. This
happened ~30% of the time on Telegram.

Root cause: the summary's 'Next Steps' section read as active instructions,
and the SUMMARY_PREFIX didn't explicitly tell the model to ignore questions
in the summary. When the summary merged into the first tail message, there
was no clear separator between historical context and the actual user message.

Changes inspired by competitor analysis (Claude Code, OpenCode, Codex):

1. SUMMARY_PREFIX rewritten with explicit 'Do NOT answer questions from
   this summary — respond ONLY to the latest user message AFTER it'

2. Summarizer preamble (shared by both prompts) adds:
   - 'Do NOT respond to any questions' (from OpenCode's approach)
   - 'Different assistant' framing (from Codex) to create psychological
     distance between summary content and active conversation

3. New summary sections:
   - '## Resolved Questions' — tracks already-answered questions with
     their answers, preventing re-answering (from Claude Code's
     'Pending user asks' pattern)
   - '## Pending User Asks' — explicitly marks unanswered questions
   - '## Remaining Work' replaces '## Next Steps' — passive framing
     avoids reading as active instructions

4. merge-summary-into-tail path now inserts a clear separator:
   '--- END OF CONTEXT SUMMARY — respond to the message below ---'

5. Iterative update prompt now instructs: 'Move answered questions to
   Resolved Questions' to maintain the resolved/pending distinction
   across multiple compactions.
2026-04-11 19:43:58 -07:00
Tom Qiao
8a48c58bd3 fix(gateway): add missing RedactingFormatter import
The gateway startup path references RedactingFormatter without
importing it, causing a NameError crash when launched with a
verbosity flag (e.g. via launchd --replace).

Fixes #8044

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 19:38:05 -07:00
Teknium
a0a02c1bc0 feat: /compress <focus> — guided compression with focus topic (#8017)
Adds an optional focus topic to /compress: `/compress database schema`
guides the summariser to preserve information related to the focus topic
(60-70% of summary budget) while compressing everything else more aggressively.
Inspired by Claude Code's /compact <focus>.

Changes:
- context_compressor.py: focus_topic parameter on _generate_summary() and
  compress(); appends FOCUS TOPIC guidance block to the LLM prompt
- run_agent.py: focus_topic parameter on _compress_context(), passed through
  to the compressor
- cli.py: _manual_compress() extracts focus topic from command string,
  preserves existing manual_compression_feedback integration (no regression)
- gateway/run.py: _handle_compress_command() extracts focus from event args
  and passes through — full gateway parity
- commands.py: args_hint="[focus topic]" on /compress CommandDef

Salvaged from PR #7459 (CLI /compress focus only — /context command deferred).
15 new tests across CLI, compressor, and gateway.
2026-04-11 19:23:29 -07:00
helix4u
cfbfc4c3f1 fix(discord): decouple readiness from slash sync 2026-04-11 19:22:14 -07:00
Teknium
fa7cd44b92 feat: add hermes backup and hermes import commands (#7997)
* feat: add `hermes backup` and `hermes import` commands

hermes backup — creates a zip of ~/.hermes/ (config, skills, sessions,
profiles, memories, skins, cron jobs, etc.) excluding the hermes-agent
codebase, __pycache__, and runtime PID files. Defaults to
~/hermes-backup-<timestamp>.zip, customizable with -o.

hermes import <zipfile> — restores from a backup zip, validating it
looks like a hermes backup before extracting. Handles .hermes/ prefix
stripping, path traversal protection, and confirmation prompts (skip
with --force).

29 tests covering exclusion rules, backup creation, import validation,
prefix detection, path traversal blocking, confirmation flow, and a
full round-trip test.

* test: improve backup/import coverage to 97%

Add 17 additional tests covering:
- _format_size helper (bytes through terabytes)
- Nonexistent hermes home error exit
- Output path is a directory (auto-names inside it)
- Output without .zip suffix (auto-appends)
- Empty hermes home (all files excluded)
- Permission errors during backup and import
- Output zip inside hermes root (skips itself)
- Not-a-zip file rejection
- EOFError and KeyboardInterrupt during confirmation
- 500+ file progress display
- Directory-only zip prefix detection

Remove dead code branch in _detect_prefix (unreachable guard).

* feat: auto-restore profile wrapper scripts on import

After extracting backup files, hermes import now scans profiles/ for
subdirectories with config.yaml or .env and recreates the ~/.local/bin
wrapper scripts so profile aliases (e.g. 'coder chat') work immediately.

Also prints guidance for re-installing gateway services per profile.

Handles edge cases:
- Skips profile dirs without config (not real profiles)
- Skips aliases that collide with existing commands
- Gracefully degrades if hermes_cli.profiles isn't available (fresh install)
- Shows PATH hint if ~/.local/bin isn't in PATH

3 new profile restoration tests (49 total).
2026-04-11 19:15:50 -07:00
Siddharth Balyan
50d86b3c71 fix(matrix): replace pickle crypto store with SQLite, fix E2EE decryption (#7981)
Fixes #7952 — Matrix E2EE completely broken after mautrix migration.

- Replace MemoryCryptoStore + pickle/HMAC persistence with mautrix's
  PgCryptoStore backed by SQLite via aiosqlite. Crypto state now
  persists reliably across restarts without fragile serialization.

- Add handle_sync() call on initial sync response so to-device events
  (queued Megolm key shares) are dispatched to OlmMachine instead of
  being silently dropped.

- Add _verify_device_keys_on_server() after loading crypto state.
  Detects missing keys (re-uploads), stale keys from migration
  (attempts re-upload), and corrupted state (refuses E2EE).

- Add _CryptoStateStore adapter wrapping MemoryStateStore to satisfy
  mautrix crypto's StateStore interface (is_encrypted,
  get_encryption_info, find_shared_rooms).

- Remove redundant share_keys() call from sync loop — OlmMachine
  already handles this via DEVICE_OTK_COUNT event handler.

- Fix datetime vs float TypeError in session.py suspend_recently_active()
  that crashed gateway startup.

- Add aiosqlite and asyncpg to [matrix] extra in pyproject.toml.

- Update test mocks for PgCryptoStore/Database and add query_keys mock
  for key verification. 174 tests pass.

- Add E2EE upgrade/migration docs to Matrix user guide.
2026-04-12 07:24:46 +05:30
Siddharth Balyan
27eeea0555 perf(ssh,modal): bulk file sync via tar pipe and tar/base64 archive (#8014)
* perf(ssh,modal): bulk file sync via tar pipe and tar/base64 archive

SSH: symlink-staging + tar -ch piped over SSH in a single TCP stream.
Eliminates per-file scp round-trips. Handles timeout (kills both
processes), SSH Popen failure (kills tar), and tar create failure.

Modal: in-memory gzipped tar archive, base64-encoded, decoded+extracted
in one exec call. Checks exit code and raises on failure.

Both backends use shared helpers extracted into file_sync.py:
- quoted_mkdir_command() — mirrors existing quoted_rm_command()
- unique_parent_dirs() — deduplicates parent dirs from file pairs

Migrates _ensure_remote_dirs to use the new helpers.

28 new tests (21 SSH + 7 Modal), all passing.

Closes #7465
Closes #7467

* fix(modal): pipe stdin to avoid ARG_MAX, clean up review findings

- Modal bulk upload: stream base64 payload through proc.stdin in 1MB
  chunks instead of embedding in command string (Modal SDK enforces
  64KB ARG_MAX_BYTES — typical payloads are ~4.3MB)
- Modal single-file upload: same stdin fix, add exit code checking
- Remove what-narrating comments in ssh.py and modal.py (keep WHY
  comments: symlink staging rationale, SIGPIPE, deadlock avoidance)
- Remove unnecessary `sandbox = self._sandbox` alias in modal bulk
- Daytona: use shared helpers (unique_parent_dirs, quoted_mkdir_command)
  instead of inlined duplicates

---------

Co-authored-by: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com>
2026-04-12 06:18:05 +05:30
Teknium
fd73937ec8 feat: component-separated logging with session context and filtering (#7991)
* feat: component-separated logging with session context and filtering

Phase 1 — Gateway log isolation:
- gateway.log now only receives records from gateway.* loggers
  (platform adapters, session management, slash commands, delivery)
- agent.log remains the catch-all (all components)
- errors.log remains WARNING+ catch-all
- Moved gateway.log handler creation from gateway/run.py into
  hermes_logging.setup_logging(mode='gateway') with _ComponentFilter

Phase 2 — Session ID injection:
- Added set_session_context(session_id) / clear_session_context() API
  using threading.local() for per-thread session tracking
- _SessionFilter enriches every log record with session_tag attribute
- Log format: '2026-04-11 10:23:45 INFO [session_id] logger.name: msg'
- Session context set at start of run_conversation() in run_agent.py
- Thread-isolated: gateway conversations on different threads don't leak

Phase 3 — Component filtering in hermes logs:
- Added --component flag: hermes logs --component gateway|agent|tools|cli|cron
- COMPONENT_PREFIXES maps component names to logger name prefixes
- Works with all existing filters (--level, --session, --since, -f)
- Logger name extraction handles both old and new log formats

Files changed:
- hermes_logging.py: _SessionFilter, _ComponentFilter, COMPONENT_PREFIXES,
  set/clear_session_context(), gateway.log creation in setup_logging()
- gateway/run.py: removed redundant gateway.log handler (now in hermes_logging)
- run_agent.py: set_session_context() at start of run_conversation()
- hermes_cli/logs.py: --component filter, logger name extraction
- hermes_cli/main.py: --component argument on logs subparser

Addresses community request for component-separated, filterable logging.
Zero changes to existing logger names — __name__ already provides hierarchy.

* fix: use LogRecord factory instead of per-handler _SessionFilter

The _SessionFilter approach required attaching a filter to every handler
we create. Any handler created outside our _add_rotating_handler (like
the gateway stderr handler, or third-party handlers) would crash with
KeyError: 'session_tag' if it used our format string.

Replace with logging.setLogRecordFactory() which injects session_tag
into every LogRecord at creation time — process-global, zero per-handler
wiring needed. The factory is installed at import time (before
setup_logging) so session_tag is available from the moment hermes_logging
is imported.

- Idempotent: marker attribute prevents double-wrapping on module reload
- Chains with existing factory: won't break third-party record factories
- Removes _SessionFilter from _add_rotating_handler and setup_verbose_logging
- Adds tests: record factory injection, idempotency, arbitrary handler compat
2026-04-11 17:23:36 -07:00
Teknium
723b5bec85 feat: per-platform display verbosity configuration (#8006)
Add display.platforms section to config.yaml for per-platform overrides of
display settings (tool_progress, show_reasoning, streaming, tool_preview_length).

Each platform gets sensible built-in defaults based on capability tier:
- High (telegram, discord): tool_progress=all, streaming follows global
- Medium (slack, mattermost, matrix, feishu): tool_progress=new
- Low (signal, whatsapp, bluebubbles, wecom, etc.): tool_progress=off, streaming=false
- Minimal (email, sms, webhook, homeassistant): tool_progress=off, streaming=false

Example config:
  display:
    platforms:
      telegram:
        tool_progress: all
        show_reasoning: true
      slack:
        tool_progress: off

Resolution order: platform override > global setting > built-in platform default.

Changes:
- New gateway/display_config.py: resolver module with tier-based platform defaults
- gateway/run.py: tool_progress, tool_preview_length, streaming, show_reasoning
  all resolve per-platform via the new resolver
- /verbose command: now cycles tool_progress per-platform (saves to
  display.platforms.<platform>.tool_progress instead of global)
- /reasoning show|hide: now saves show_reasoning per-platform
- Config version 15 -> 16: migrates tool_progress_overrides into display.platforms
- Backward compat: legacy tool_progress_overrides still read as fallback
- 27 new tests for resolver, normalization, migration, backward compat
- Updated verbose command tests for per-platform behavior

Addresses community request for per-channel verbosity control (Guillaume Meyer,
Nathan Danielsen) — high verbosity on backchannel Telegram, low on customer-facing
Slack, none on email.
2026-04-11 17:20:34 -07:00
Teknium
14ccd32cee refactor(terminal): remove check_interval parameter (#8001)
The check_interval parameter on terminal_tool sent periodic output
updates to the gateway chat, but these were display-only — the agent
couldn't see or act on them. This added schema bloat and introduced
a bug where notify_on_complete=True was silently dropped when
check_interval was also set (the not-check_interval guard skipped
fast-watcher registration, and the check_interval watcher dict
was missing the notify_on_complete key).

Removing check_interval entirely:
- Eliminates the notify_on_complete interaction bug
- Reduces tool schema size (one fewer parameter for the model)
- Simplifies the watcher registration path
- notify_on_complete (agent wake-on-completion) still works
- watch_patterns (output alerting) still works
- process(action='poll') covers manual status checking

Closes #7947 (root cause eliminated rather than patched).
2026-04-11 17:16:11 -07:00
Mateus Scheuer Macedo
06f862fa1b feat(cli): add native /model picker modal for provider → model selection
When /model is called with no arguments in the interactive CLI, open a
two-step prompt_toolkit modal instead of the previous text-only listing:

1. Provider selection — curses_single_select with all authenticated providers
2. Model selection — live API fetch with curated fallback

Also fixes:
- OpenAI Codex model normalization (openai/gpt-5.4 → gpt-5.4)
- Dedicated Codex validation path using provider_model_ids()

Preserves curses_radiolist (used by setup, tools, plugins) alongside the
new curses_single_select. Retains tool elapsed timer in spinner.

Cherry-picked from PR #7438 by MestreY0d4-Uninter.
2026-04-11 17:16:06 -07:00
Teknium
39cd57083a refactor: remove budget warning injection system (dead code)
The _get_budget_warning() method already returned None unconditionally —
the entire budget warning system was disabled. Remove all dead code:

- _BUDGET_WARNING_RE regex
- _strip_budget_warnings_from_history() function and its call site
- Both injection blocks (concurrent + sequential tool execution)
- _get_budget_warning() method
- 7 tests for the removed functions

The budget exhaustion grace call system (_budget_exhausted_injected,
_budget_grace_call) is a separate recovery mechanism and is preserved.
2026-04-11 16:56:33 -07:00
waxinz
d99e2a29d6 feat: standardize message whitespace and JSON formatting
Normalize api_messages before each API call for consistent prefix
matching across turns:

1. Strip leading/trailing whitespace from system prompt parts
2. Strip leading/trailing whitespace from message content strings
3. Normalize tool-call arguments to compact sorted JSON

This enables KV cache reuse on local inference servers (llama.cpp,
vLLM, Ollama) and improves cache hit rates for cloud providers.

All normalization operates on the api_messages copy — the original
conversation history in messages is never mutated.  Tool-call JSON
normalization creates new dicts via spread to avoid the shallow-copy
mutation bug in the original PR.

Salvaged from PR #7875 by @waxinz with mutation fix.
2026-04-11 16:49:44 -07:00
Siddharth Balyan
cab814af15 feat(nix): container-aware CLI — auto-route into managed container (#7543)
* feat(nix): container-aware CLI — auto-route all subcommands into managed container

When container.enable = true, the host `hermes` CLI transparently execs
every subcommand into the managed Docker/Podman container. A symlink
bridge (~/.hermes -> /var/lib/hermes/.hermes) unifies state between host
and container so sessions, config, and memories are shared.

CLI changes:
- Global routing before subcommand dispatch (all commands forwarded)
- docker exec with -u exec_user, env passthrough (TERM, COLORTERM,
  LANG, LC_ALL), TTY-aware flags
- Retry with spinner on failure (TTY: 5s, non-TTY: 10s silent)
- Hard fail instead of silent fallback
- HERMES_DEV=1 env var bypasses routing for development
- No routing messages (invisible to user)

NixOS module changes:
- container.hostUsers option: lists users who get ~/.hermes symlink
  and automatic hermes group membership
- Activation script creates symlink bridge (with backup of existing
  ~/.hermes dirs), writes exec_user to .container-mode
- Cleanup on disable: removes symlinks + .container-mode + stops service
- Warning when hostUsers set without addToSystemPackages

* fix: address review — reuse sudo var, add chown -h on symlink update

- hermes_cli/main.py: reuse the existing `sudo` variable instead of
  redundant `shutil.which("sudo")` call that could return None
- nix/nixosModules.nix: add missing `chown -h` when updating an
  existing symlink target so ownership stays consistent with the
  fresh-create and backup-replace branches

* fix: address remaining review items from cursor bugbot

- hermes_cli/main.py: move container routing BEFORE parse_args() so
  --help, unrecognised flags, and all subcommands are forwarded
  transparently into the container instead of being intercepted by
  argparse on the host (high severity)

- nix/nixosModules.nix: resolve home dirs via
  config.users.users.${user}.home instead of hardcoding /home/${user},
  supporting users with custom home directories (medium severity)

- nix/nixosModules.nix: gate hostUsers group membership on
  container.enable so setting hostUsers without container mode doesn't
  silently add users to the hermes group (low severity)

* fix: simplify container routing — execvp, no retries, let it crash

- Replace subprocess.run retry loop with os.execvp (no idle parent process)
- Extract _probe_container helper for sudo detection with 15s timeout
- Narrow exception handling: FileNotFoundError only in get_container_exec_info,
  catch TimeoutExpired specifically, remove silent except Exception: pass
- Collapse needs_sudo + sudo into single sudo_path variable
- Simplify NixOS symlink creation from 4 branches to 2
- Gate NixOS sudoers hint with "On NixOS:" prefix
- Full test rewrite: 18 tests covering execvp, sudo probe, timeout, permissions

---------

Co-authored-by: Hermes Agent <hermes@nousresearch.com>
2026-04-12 05:17:46 +05:30
Teknium
5c2ecdec49 fix: use ceiling division for token estimation, deduplicate inline formula
Switch estimate_tokens_rough(), estimate_messages_tokens_rough(), and
estimate_request_tokens_rough() from floor division (len // 4) to
ceiling division ((len + 3) // 4). Short texts (1-3 chars) previously
estimated as 0 tokens, causing the compressor and pre-flight checks to
systematically undercount when many short tool results are present.

Also replaced the inline duplicate formula in run_conversation()
(total_chars // 4) with a call to the shared
estimate_messages_tokens_rough() function.

Updated 4 tests that hardcoded floor-division expected values.

Related: issue #6217, PR #6629
2026-04-11 16:33:40 -07:00
WAXLYY
6d272ba477 fix(tools): enforce ID uniqueness in TODO store during replace operations
Deduplicate todo items by ID before writing to the store, keeping the
last occurrence. Prevents ghost entries when the model sends duplicate
IDs in a single write() call, which corrupts subsequent merge operations.

Co-authored-by: WAXLYY <WAXLYY@users.noreply.github.com>
2026-04-11 16:22:50 -07:00
asheriif
97b0cd51ee feat(gateway): surface natural mid-turn assistant messages in chat platforms
Add display.interim_assistant_messages config (enabled by default) that
forwards completed assistant commentary between tool calls to the user
as separate chat messages. Models already emit useful status text like
'I'll inspect the repo first.' — this surfaces it on Telegram, Discord,
and other messaging platforms instead of swallowing it.

Independent from tool_progress and gateway streaming. Disabled for
webhooks. Uses GatewayStreamConsumer when available, falls back to
direct adapter send. Tracks response_previewed to prevent double-delivery
when interim message matches the final response.

Also fixes: cursor not stripped from fallback prefix in stream consumer
(affected continuation calculation on no-edit platforms like Signal).

Cherry-picked from PR #7885 by asheriif, default changed to enabled.
Fixes #5016
2026-04-11 16:21:39 -07:00
Teknium
6ee0005e8c docs: expand tool-use enforcement documentation (#7984)
- Fix auto list (was only gpt, actually includes codex/gemini/gemma/grok)
- Document the three guidance layers (general, OpenAI-specific, Google-specific)
- Add 'When to turn it on' section for users on non-default models
- Clarify that substring matching is case-insensitive
2026-04-11 16:20:27 -07:00
Teknium
c8aff74632 fix: prevent agent from stopping mid-task — compression floor, budget overhaul, activity tracking
Three root causes of the 'agent stops mid-task' gateway bug:

1. Compression threshold floor (64K tokens minimum)
   - The 50% threshold on a 100K-context model fired at 50K tokens,
     causing premature compression that made models lose track of
     multi-step plans.  Now threshold_tokens = max(50% * context, 64K).
   - Models with <64K context are rejected at startup with a clear error.

2. Budget warning removal — grace call instead
   - Removed the 70%/90% iteration budget warnings entirely.  These
     injected '[BUDGET WARNING: Provide your final response NOW]' into
     tool results, causing models to abandon complex tasks prematurely.
   - Now: no warnings during normal execution.  When the budget is
     actually exhausted (90/90), inject a user message asking the model
     to summarise, allow one grace API call, and only then fall back
     to _handle_max_iterations.

3. Activity touches during long terminal execution
   - _wait_for_process polls every 0.2s but never reported activity.
     The gateway's inactivity timeout (default 1800s) would fire during
     long-running commands that appeared 'idle.'
   - Now: thread-local activity callback fires every 10s during the
     poll loop, keeping the gateway's activity tracker alive.
   - Agent wires _touch_activity into the callback before each tool call.

Also: docs update noting 64K minimum context requirement.

Closes #7915 (root cause was agent-loop termination, not Weixin delivery limits).
2026-04-11 16:18:57 -07:00
Teknium
08f35076c9 fix: always log outer loop exception traceback at DEBUG level
Replace the verbose_logging-gated logging.exception() with an
unconditional logger.debug(exc_info=True). The full traceback now
always lands in agent.log when debug logging is enabled, without
requiring the verbose_logging flag or spamming the console.

Previously, production errors in the 700-line response processing
block (normalization, tool dispatch, final response handling) were
logged as one-line messages with the traceback hidden behind
verbose_logging — making post-mortem debugging difficult.
2026-04-11 15:52:07 -07:00
Teknium
289d2745af docs: add platform adapter developer guide + WeCom Callback docs (#7969)
Add the missing 'Adding a Platform Adapter' developer guide — a
comprehensive step-by-step checklist covering all 20+ integration
points (enum, adapter, config, runner, CLI, tools, toolsets, cron,
webhooks, tests, and docs). Includes common patterns for long-poll,
callback/webhook, and token-lock adapters with reference implementations.

Also adds full docs coverage for the WeCom Callback platform:
- New docs page: user-guide/messaging/wecom-callback.md
- Environment variables reference (9 WECOM_CALLBACK_* vars)
- Toolsets reference (hermes-wecom-callback)
- Messaging index (comparison table, architecture diagram, toolsets,
  security, next-steps links)
- Integrations index listing
- Sidebar entries for both new pages
2026-04-11 15:50:54 -07:00
Koichi Tsutsumi
fc417ed049 fix(cli): add ChatConsole.status for /skills search 2026-04-11 15:38:43 -07:00
0xbyt4
32519066dc fix(gateway): add HERMES_SESSION_KEY to session_context contextvars
Complete the contextvars migration by adding HERMES_SESSION_KEY to the
unified _VAR_MAP in session_context.py. Without this, concurrent gateway
handlers race on os.environ["HERMES_SESSION_KEY"].

- Add _SESSION_KEY ContextVar to _VAR_MAP, set_session_vars(), clear_session_vars()
- Wire session_key through _set_session_env() from SessionContext
- Replace os.getenv fallback in tools/approval.py with get_session_env()
  (function-level import to avoid cross-layer coupling)
- Keep os.environ set as CLI/cron fallback

Cherry-picked from PR #7878 by 0xbyt4.
2026-04-11 15:35:04 -07:00
syaor4n
689c515090 feat: add --env and --preset support to hermes mcp add
- Add --env KEY=VALUE for passing environment variables to stdio MCP servers
- Add --preset for known MCP server templates (empty for now, extensible)
- Validate env var names, reject --env for HTTP servers
- Explicit --command/--url overrides preset defaults
- Remove unused getpass import

Based on PR #7936 by @syaor4n (stitch preset removed, generic infra kept).
2026-04-11 15:34:57 -07:00
Teknium
758c4ad1ef fix: remove dead hasattr checks for retry counters initialized in reset block
All retry counters (_invalid_tool_retries, _invalid_json_retries,
_empty_content_retries, _incomplete_scratchpad_retries,
_codex_incomplete_retries) are initialized to 0 at the top of
run_conversation() (lines 7566-7570). The hasattr guards added before
the reset block existed are now dead code — the attributes always exist.

Removed 7 redundant hasattr checks (5 original targets + 2 bonus for
_codex_incomplete_retries found during cleanup).
2026-04-11 15:29:15 -07:00
Teknium
000a881fcf fix: reset compression_attempts and primary_recovery_attempted on fallback activation
When _try_activate_fallback() switches to a new provider, retry_count was
reset to 0 but compression_attempts and primary_recovery_attempted were
not. This meant a fallback provider that hit context overflow would only
get the leftover compression budget from the failed primary provider,
and transport recovery was blocked because the flag was still True from
the old provider's attempt.

Reset both counters at all 5 fallback activation sites inside the retry
loop so each fallback provider gets a fresh compression budget (3 attempts)
and its own transport recovery opportunity.
2026-04-11 15:26:24 -07:00
chqchshj
5f0caf54d6 feat(gateway): add WeCom callback-mode adapter for self-built apps
Add a second WeCom integration mode for regular enterprise self-built
applications.  Unlike the existing bot/websocket adapter (wecom.py),
this handles WeCom's standard callback flow: WeCom POSTs encrypted XML
to an HTTP endpoint, the adapter decrypts, queues for the agent, and
immediately acknowledges.  The agent's reply is delivered proactively
via the message/send API.

Key design choice: always acknowledge immediately and use proactive
send — agent sessions take 3-30 minutes, so the 5-second inline reply
window is never useful.  The original PR's Future/pending-reply
machinery was removed in favour of this simpler architecture.

Features:
- AES-CBC encrypt/decrypt (BizMsgCrypt-compatible)
- Multi-app routing scoped by corp_id:user_id
- Legacy bare user_id fallback for backward compat
- Access-token management with auto-refresh
- WECOM_CALLBACK_* env var overrides
- Port-in-use pre-check before binding
- Health endpoint at /health

Salvaged from PR #7774 by @chqchshj.  Simplified by removing the
inline reply Future system and fixing: secrets.choice for nonce
generation, immediate plain-text acknowledgment (not encrypted XML
containing 'success'), and initial token refresh error handling.
2026-04-11 15:22:49 -07:00
faishal
90352b2adf fix: normalize checkpoint manager home-relative paths
Adds _normalize_path() helper that calls expanduser().resolve() to
properly handle tilde paths (e.g. ~/.hermes, ~/.config).  Previously
Path.resolve() alone treated ~ as a literal directory name, producing
invalid paths like /root/~/.hermes.

Also improves _run_git() error handling to distinguish missing working
directories from missing git executable, and adds pre-flight directory
validation.

Cherry-picked from PR #7898 by faishal882.
Fixes #7807
2026-04-11 14:50:44 -07:00
SHL0MS
ee39e88b03 fix(claw): warn if gateway is running before migrating bot tokens
When 'hermes claw migrate' copies Telegram/Discord/Slack bot tokens from
OpenClaw while the Hermes gateway is already polling with those same tokens,
the platforms conflict (e.g. Telegram 409). Add a pre-flight check that reads
gateway_state.json via get_running_pid() + read_runtime_status(), warns the
user, and lets them cancel or continue.

Also improve the Telegram polling conflict error message to mention OpenClaw
as a common cause and give the 'hermes start' restart command.

Refs #7907
2026-04-11 14:49:21 -07:00
Teknium
b53f681993 fix(cron): pass skip_context_files=True to AIAgent in run_job (#7958)
Cron jobs run from whatever directory the scheduler process lives in
(typically the hermes-agent install dir), so without this flag the agent
picks up AGENTS.md, SOUL.md, or .cursorrules from that cwd — injecting
irrelevant project context into the cron job's system prompt.

batch_runner.py and gateway boot_md already pass skip_context_files=True
for the same reason. This aligns cron with the established pattern for
autonomous/headless agent runs.
2026-04-11 14:48:58 -07:00
Teknium
8c3935ebe8 fix: is_local_endpoint misses Docker/Podman DNS names (#7950)
* fix(tools): neutralize shell injection in _write_to_sandbox via path quoting

_write_to_sandbox interpolated storage_dir and remote_path directly into
a shell command passed to env.execute(). Paths containing shell
metacharacters (spaces, semicolons, $(), backticks) could trigger
arbitrary command execution inside the sandbox.

Fix: wrap both paths with shlex.quote(). Clean paths (alphanumeric +
slashes/hyphens/dots) are left unmodified by shlex.quote, so existing
behavior is unchanged. Paths with unsafe characters get single-quoted.

Tests added for spaces, $(command) substitution, and semicolon injection.

* fix: is_local_endpoint misses Docker/Podman DNS names

host.docker.internal, host.containers.internal, gateway.docker.internal,
and host.lima.internal are well-known DNS names that container runtimes
use to resolve the host machine. Users running Ollama on the host with
the agent in Docker/Podman hit the default 120s stream timeout instead
of the bumped 1800s because these hostnames weren't recognized as local.

Add _CONTAINER_LOCAL_SUFFIXES tuple and suffix check in
is_local_endpoint(). Tests cover all three runtime families plus a
negative case for domains that merely contain the suffix as a substring.
2026-04-11 14:46:18 -07:00
Teknium
1e5056ec30 feat(gateway): add all missing platforms to interactive setup wizard (#7949)
Wire Signal, Email, SMS (Twilio), DingTalk, Feishu/Lark, and WeCom into
the hermes setup gateway interactive wizard. These platforms all had
working adapters and _PLATFORMS entries in gateway.py but were invisible
in the setup checklist — users had to manually edit .env to configure them.

Changes:
- gateway.py: Add _setup_email/sms/dingtalk/feishu/wecom functions
  delegating to _setup_standard_platform (Signal already had a custom one)
- setup.py: Add wrapper functions for all 6 new platforms
- setup.py: Add all 6 to _GATEWAY_PLATFORMS checklist registry
- setup.py: Add missing env vars to any_messaging check
- setup.py: Add all missing platforms to _get_section_config_summary
  (was also missing Matrix, Mattermost, Weixin, Webhooks)
- docs: Add FEISHU_ALLOWED_USERS and WECOM_ALLOWED_USERS examples

Incorporates and extends the work from PR #7918 by bugmaker2.
2026-04-11 14:44:51 -07:00
Teknium
d82580b25b fix: add all_profiles param + narrow exception handling
- add all_profiles=False to find_gateway_pids() and
  kill_gateway_processes() so hermes update and gateway stop --all
  can still discover processes across all profiles
- narrow bare 'except Exception' to (OSError, subprocess.TimeoutExpired)
- update test mocks to match new signatures
2026-04-11 14:44:29 -07:00
Dominic Grieco
b80e318168 fix: scope gateway status to the active profile 2026-04-11 14:44:29 -07:00
etcircle
72b345e068 fix(gateway): preserve queued voice events for STT 2026-04-11 14:43:53 -07:00
Teknium
8160d7a03d test: add dedup coverage for reasoning item ID deduplication
Adds two tests verifying that duplicate reasoning item IDs across
multi-turn Codex Responses conversations are correctly deduplicated
in both _chat_messages_to_responses_input() and
_preflight_codex_input_items().
2026-04-11 14:43:47 -07:00
sauljwu
dfe7386a58 fix: deduplicate reasoning items in Responses API input
When replaying codex_reasoning_items from previous turns,
duplicate item IDs (rs_*) could appear in the input array,
causing HTTP 400 "Duplicate item found" errors from the
OpenAI Responses API.

Add seen_item_ids tracking in both _chat_messages_to_responses_input()
and _preflight_codex_input_items() to skip already-added reasoning
items by their ID.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 14:43:47 -07:00
willy-scr
ef73babea1 fix(gateway): use source.thread_id instead of undefined event in queued response
In _run_agent(), the pending message handler references 'event' which
is not defined in that scope — it only exists in the caller. This
causes a NameError when sending the first response before processing a
queued follow-up message.

Replace getattr(event, 'metadata', None) with the established pattern
using source.thread_id, consistent with lines 2625, 2810, 3678, 4410, 4566
in the same file.
2026-04-11 14:26:20 -07:00
Teknium
f2893fe51a fix(tools): neutralize shell injection in _write_to_sandbox via path quoting (#7940)
_write_to_sandbox interpolated storage_dir and remote_path directly into
a shell command passed to env.execute(). Paths containing shell
metacharacters (spaces, semicolons, $(), backticks) could trigger
arbitrary command execution inside the sandbox.

Fix: wrap both paths with shlex.quote(). Clean paths (alphanumeric +
slashes/hyphens/dots) are left unmodified by shlex.quote, so existing
behavior is unchanged. Paths with unsafe characters get single-quoted.

Tests added for spaces, $(command) substitution, and semicolon injection.
2026-04-11 14:26:11 -07:00
Dusk1e
255f59de18 fix(tools): prevent command argument injection and path traversal in checkpoint manager
This commit addresses a security vulnerability where unsanitized user inputs for commit_hash and file_path were passed directly to git commands in CheckpointManager.restore() and diff(). It validates commit hashes to be strictly hexadecimal characters without leading dashes (preventing flag injection like '--patch') and enforces file paths to stay within the working directory via root resolution. Regression tests test_restore_rejects_argument_injection, test_restore_rejects_invalid_hex_chars, and test_restore_rejects_path_traversal were added.
2026-04-11 14:25:57 -07:00
Teknium
4bede272cf fix: propagate model through credential pool path + add tests
The cherry-picked fix from PR #7916 placed model propagation after
the credential pool early-return in _resolve_named_custom_runtime(),
making it dead code when a pool is active (which happens whenever
custom_providers has an api_key that auto-seeds the pool).

- Inject model into pool_result before returning
- Add 5 regression tests covering direct path, pool path, empty
  model, and absent model scenarios
- Add 'model' to _VALID_CUSTOM_PROVIDER_FIELDS for config validation
2026-04-11 14:09:40 -07:00
0xFrank-eth
0e6354df50 fix(custom-providers): propagate model field from config to runtime so API receives the correct model name
Fixes #7828

When a custom_providers entry carries a `model` field, that value was
silently dropped by `_get_named_custom_provider` and
`_resolve_named_custom_runtime`.  Callers received a runtime dict with
`base_url`, `api_key`, and `api_mode` — but no `model`.

As a result, `hermes chat --model <provider-name>` sent the *provider
name* (e.g. "my-dashscope-provider") as the model string to the API
instead of the configured model (e.g. "qwen3.6-plus"), producing:

    Error code: 400 - {'error': {'message': 'Model Not Exist'}}

Setting the provider as the *default* model in config.yaml worked
because that path writes `model.default` and the agent reads it back
directly, bypassing the broken runtime resolution path.

Changes:

1. hermes_cli/runtime_provider.py — _get_named_custom_provider()
   Reads `entry.get("model")` and includes it in the result dict so
   the value is available to callers.

2. hermes_cli/runtime_provider.py — _resolve_named_custom_runtime()
   Propagates `custom_provider["model"]` into the returned runtime dict.

3. cli.py — _ensure_runtime_credentials()
   After resolving runtime, if `runtime["model"]` is set, assign it to
   `self.model` so the AIAgent is initialised with the correct model
   name rather than the provider name the user typed on the CLI.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 14:09:40 -07:00
Teknium
b0892375cd fix: mock aiohttp server in startup guard tests to avoid port binding
The startup guard tests called connect() which bound a real aiohttp
server on port 8080 — flaky in any environment where the port is
in use. Mock AppRunner, TCPSite, and ClientSession instead.
2026-04-11 14:05:38 -07:00
Mariano Nicolini
0a922bf218 add new test covering edge case where both insecure_no_sig and _webhook_url are set 2026-04-11 14:05:38 -07:00
Mariano Nicolini
d053845703 remove unused import and fix misleading log 2026-04-11 14:05:38 -07:00
Mariano Nicolini
0970f1de50 update docks with changes made 2026-04-11 14:05:38 -07:00
Mariano Nicolini
8ce6aaac23 change Twilio signature verification from opt-in to opt-out 2026-04-11 14:05:38 -07:00
Mariano Nicolini
ad1e8804a6 handle port variants in Twilio signatures 2026-04-11 14:05:38 -07:00
Mariano Nicolini
c22bffc92e add basic twilio signature checking and tests 2026-04-11 14:05:38 -07:00
Teknium
cc4b1f0007 fix(whatsapp): pin Baileys to fix/abprops-abt-fetch for bad-request fix
WhatsApp changed their server protocol for property queries, causing
400 bad-request errors in fetchProps/executeInitQueries on every
reconnect (Baileys issue #2477). The fix in PR #2473 changes the IQ
namespace from 'w' to 'abt' and protocol from '2' to '1'.

Pin to the fix branch until the next Baileys release includes it.
2026-04-11 14:03:37 -07:00
Teknium
dfc820345d fix: scope tool interrupt signal per-thread to prevent cross-session leaks (#7930)
The interrupt mechanism in tools/interrupt.py used a process-global
threading.Event. In the gateway, multiple agents run concurrently in
the same process via run_in_executor. When any agent was interrupted
(user sends a follow-up message), the global flag killed ALL agents'
running tools — terminal commands, browser ops, web requests — across
all sessions.

Changes:
- tools/interrupt.py: Replace single threading.Event with a set of
  interrupted thread IDs. set_interrupt() targets a specific thread;
  is_interrupted() checks the current thread. Includes a backward-
  compatible _ThreadAwareEventProxy for legacy _interrupt_event usage.
- run_agent.py: Store execution thread ID at start of run_conversation().
  interrupt() and clear_interrupt() pass it to set_interrupt() so only
  this agent's thread is affected.
- tools/code_execution_tool.py: Use is_interrupted() instead of
  directly checking _interrupt_event.is_set().
- tools/process_registry.py: Same — use is_interrupted().
- tests: Update interrupt tests for per-thread semantics. Add new
  TestPerThreadInterruptIsolation with two tests verifying cross-thread
  isolation.
2026-04-11 14:02:58 -07:00
Teknium
75380de430 fix: reap orphaned browser sessions on startup (#7931)
When a Python process exits uncleanly (SIGKILL, crash, gateway restart
via hermes update), in-memory _active_sessions tracking is lost but the
agent-browser node daemons and their Chromium child processes keep
running indefinitely. On a long-running system this causes unbounded
memory growth — 24 orphaned sessions consumed 7.6 GB on a production
machine over 9 days.

Add _reap_orphaned_browser_sessions() which scans the tmp directory for
agent-browser-{h_*,cdp_*} socket dirs on cleanup thread startup.  For
each dir not tracked by the current process, reads the daemon PID file
and sends SIGTERM if the daemon is still alive.  Handles edge cases:
dead PIDs, corrupt PID files, permission errors, foreign processes.

The reaper runs once on thread startup (not every 30s) to avoid races
with sessions being actively created by concurrent agents.
2026-04-11 14:02:46 -07:00
Markus Corazzione
885123d44b fix(weixin): add per-chunk retry with backoff for text delivery
When sending multi-chunk responses, individual chunks can fail due to
transient iLink API errors. Previously a single failure would abort the
entire message. Now each chunk is retried with linear backoff before
giving up, and the same client_id is reused across retries for
server-side deduplication.

Configurable via config.yaml (platforms.weixin.extra) or env vars:
- send_chunk_delay_seconds (default 0.35s) — pacing between chunks
- send_chunk_retries (default 2) — max retry attempts per chunk
- send_chunk_retry_delay_seconds (default 1.0s) — base retry delay

Replaces the hardcoded 0.3s inter-chunk delay from #7903.

Salvaged from PR #7899 by @corazzione. Fixes #7836.
2026-04-11 14:02:33 -07:00
Teknium
04c1c5d53f refactor: extract shared helpers to deduplicate repeated code patterns (#7917)
* refactor: add shared helper modules for code deduplication

New modules:
- gateway/platforms/helpers.py: MessageDeduplicator, TextBatchAggregator,
  strip_markdown, ThreadParticipationTracker, redact_phone
- hermes_cli/cli_output.py: print_info/success/warning/error, prompt helpers
- tools/path_security.py: validate_within_dir, has_traversal_component
- utils.py additions: safe_json_loads, read_json_file, read_jsonl,
  append_jsonl, env_str/lower/int/bool helpers
- hermes_constants.py additions: get_config_path, get_skills_dir,
  get_logs_dir, get_env_path

* refactor: migrate gateway adapters to shared helpers

- MessageDeduplicator: discord, slack, dingtalk, wecom, weixin, mattermost
- strip_markdown: bluebubbles, feishu, sms
- redact_phone: sms, signal
- ThreadParticipationTracker: discord, matrix
- _acquire/_release_platform_lock: telegram, discord, slack, whatsapp,
  signal, weixin

Net -316 lines across 19 files.

* refactor: migrate CLI modules to shared helpers

- tools_config.py: use cli_output print/prompt + curses_radiolist (-117 lines)
- setup.py: use cli_output print helpers + curses_radiolist (-101 lines)
- mcp_config.py: use cli_output prompt (-15 lines)
- memory_setup.py: use curses_radiolist (-86 lines)

Net -263 lines across 5 files.

* refactor: migrate to shared utility helpers

- safe_json_loads: agent/display.py (4 sites)
- get_config_path: skill_utils.py, hermes_logging.py, hermes_time.py
- get_skills_dir: skill_utils.py, prompt_builder.py
- Token estimation dedup: skills_tool.py imports from model_metadata
- Path security: skills_tool, cronjob_tools, skill_manager_tool, credential_files
- Non-atomic YAML writes: doctor.py, config.py now use atomic_yaml_write
- Platform dict: new platforms.py, skills_config + tools_config derive from it
- Anthropic key: new get_anthropic_key() in auth.py, used by doctor/status/config/main

* test: update tests for shared helper migrations

- test_dingtalk: use _dedup.is_duplicate() instead of _is_duplicate()
- test_mattermost: use _dedup instead of _seen_posts/_prune_seen
- test_signal: import redact_phone from helpers instead of signal
- test_discord_connect: _platform_lock_identity instead of _token_lock_identity
- test_telegram_conflict: updated lock error message format
- test_skill_manager_tool: 'escapes' instead of 'boundary' in error msgs
2026-04-11 13:59:52 -07:00
dalianmao000
cf53e2676b fix(wecom): handle appmsg attachments (PDF/Word/Excel) from WeCom AI Bot
WeCom AI Bot sends file attachments with msgtype="appmsg", not
msgtype="file". Previously only file content was discarded while
the text title reached the agent.

Changes:
- _extract_text(): Extract appmsg title (filename) for display
- _extract_media(): Handle appmsg type with file/image content

Fixes #7750

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 13:48:25 -07:00
WAXLYY
f4f4078ad9 fix(gateway/weixin): ensure atomic persistence for critical session state 2026-04-11 13:48:25 -07:00
Teknium
59e630a64d fix: update thinking-exhaustion test for think-tag gating
The test expected content=None to immediately trigger thinking-exhaustion,
but PR #7738 correctly gates that check on _has_think_tags. Without think
tags, the agent falls through to normal continuation retry (3 attempts).
2026-04-11 13:47:25 -07:00
konsisumer
2d328d5c70 fix(gateway): break stuck session resume loops on restart (#7536)
Cherry-picked from PR #7747 with follow-up fixes:
- Narrowed suspend_all_active() to suspend_recently_active() — only
  suspends sessions updated within the last 2 minutes (likely in-flight),
  not all sessions which would unnecessarily reset idle users
- /stop with no running agent no longer suspends the session; only
  actual force-stops mark the session for reset
2026-04-11 13:47:25 -07:00
ygd58
151654851c fix(agent): prevent false thinking-exhaustion for non-reasoning models
Models that do not use <think> tags (e.g. GLM-4.7 on NVIDIA Build,
minimax) may return content=None or empty string when truncated. The
previous _thinking_exhausted check treated any None/empty content as
thinking-budget exhaustion, causing these models to always show the
'Thinking Budget Exhausted' error instead of attempting continuation.

Fix: gate the exhaustion check on _has_think_tags — only trigger the
exhaustion path when the model actually produced reasoning blocks
(<think>, <thinking>, <reasoning>, <REASONING_SCRATCHPAD>). Models
without think tags now fall through to the normal continuation retry
logic (up to 3 attempts).

Fixes #7729
2026-04-11 13:47:25 -07:00
Tom Qiao
5910412002 fix: detect truncated tool_calls when finish_reason is not length
When API routers rewrite finish_reason from "length" to "tool_calls",
truncated JSON arguments bypassed the length handler and wasted 3
retry attempts in the generic JSON validation loop. Now detects
truncation patterns in tool call arguments regardless of finish_reason.

Fixes #7680

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 13:47:25 -07:00
helix4u
39da23a129 fix(api-server): keep chat-completions SSE alive 2026-04-11 13:47:25 -07:00
Teknium
cac6178104 fix(gateway): propagate user identity through process watcher pipeline
Background process watchers (notify_on_complete, check_interval) created
synthetic SessionSource objects without user_id/user_name. While the
internal=True bypass (1d8d4f28) prevented false pairing for agent-
generated notifications, the missing identity caused:

- Garbage entries in pairing rate limiters (discord:None, telegram:None)
- 'User None' in approval messages and logs
- No user identity available for future code paths that need it

Additionally, platform messages arriving without from_user (Telegram
service messages, channel forwards, anonymous admin actions) could still
trigger false pairing because they are not internal events.

Fix:
1. Propagate user_id/user_name through the full watcher chain:
   session_context.py → gateway/run.py → terminal_tool.py →
   process_registry.py (including checkpoint persistence/recovery)

2. Add None user_id guard in _handle_message() — silently drop
   non-internal messages with no user identity instead of triggering
   the pairing flow.

Salvaged from PRs #7664 (kagura-agent, ContextVar approach),
#6540 (MestreY0d4-Uninter, tests), and #7709 (guang384, None guard).

Closes #6341, #6485, #7643
Relates to #6516, #7392
2026-04-11 13:46:16 -07:00
Teknium
dafe443beb feat: warn at session start when compression model context is too small (#7894)
Two-phase design so the warning fires before the user's first message
on every platform:

Phase 1 (__init__):
  _check_compression_model_feasibility() runs during agent construction.
  Resolves the auxiliary compression model (same chain as call_llm with
  task='compression'), compares its context length to the main model's
  compression threshold. If too small, emits via _emit_status() (prints
  for CLI) and stores the warning in _compression_warning.

Phase 2 (run_conversation, first call):
  _replay_compression_warning() re-sends the stored warning through
  status_callback — which the gateway wires AFTER construction. The
  warning is then cleared so it only fires once.

This ensures:
- CLI users see the warning immediately at startup (right after the
  context limit line)
- Gateway users (Telegram, Discord, Slack, WhatsApp, Signal, Matrix,
  Mattermost, Home Assistant, DingTalk, etc.) receive it via
  status_callback('lifecycle', ...) on their first message
- logger.warning() always hits agent.log regardless of platform

Also warns when no auxiliary LLM provider is configured at all.
Entire check wrapped in try/except — never blocks startup.

11 tests covering: core warning logic, boundary conditions, exception
safety, two-phase store+replay, gateway callback wiring, and
single-delivery guarantee.
2026-04-11 12:01:30 -07:00
Teknium
da9f96bf51 fix(weixin): keep multi-line messages in single bubble by default (#7903)
The Weixin adapter was splitting responses at every top-level newline,
causing notification spam (up to 70 API calls for a single long markdown
response). This salvages the best aspects of six contributor PRs:

Compact mode (new default):
- Messages under the 4000-char limit stay as a single bubble even with
  multiple lines, paragraphs, and code blocks
- Only oversized messages get split at logical markdown boundaries
- Inter-chunk delay (0.3s) between chunks prevents WeChat rate-limit drops

Legacy mode (opt-in):
- Set split_multiline_messages: true in platforms.weixin.extra config
- Or set WEIXIN_SPLIT_MULTILINE_MESSAGES=true env var
- Restores the old per-line splitting behavior

Salvaged from PRs #7797 (guantoubaozi), #7792 (luoxiao6645),
#7838 (qyx596), #7825 (weedge), #7784 (sherunlock03), #7773 (JnyRoad).
Core fix unanimous across all six; config toggle from #7838; inter-chunk
delay from #7825.
2026-04-11 12:00:05 -07:00
0xbyt4
3ec8809b78 fix(vision): preserve aspect ratio during auto-resize
Independent halving of width and height caused aspect ratio distortion
for extreme dimensions (e.g. 8000x200 panoramas). When one axis hit the
64px floor, the other kept shrinking — collapsing the ratio toward 1:1.

Use proportional scaling instead: when either dimension hits the floor,
derive the effective scale factor and apply it to both axes.

Add tests for extreme panorama (8000x200) and tall narrow (200x6000)
images to verify aspect ratio preservation.
2026-04-11 11:53:04 -07:00
Teknium
4e3e87b677 feat(migration): preview-then-confirm UX + docs updates
hermes claw migrate now always shows a full dry-run preview before
making any changes. The user reviews what would be imported, then
confirms to proceed. --dry-run stops after the preview. --yes skips
the confirmation prompt.

This matches the existing setup wizard flow (_offer_openclaw_migration)
which already did preview-then-confirm.

Docs updated across both docs/migration/openclaw.md and
website/docs/guides/migrate-from-openclaw.md to reflect:
- New preview-first UX flow
- workspace-main/ fallback paths
- accounts.default channel token layout
- TTS edge/microsoft rename
- openclaw.json env sub-object as API key source
- Hyphenated provider API types
- Matrix accessToken field
- SecretRef file/exec warnings
- Skills session restart note
- WhatsApp re-pairing note
- Archive cleanup step
2026-04-11 11:35:23 -07:00
Teknium
26bbb422b1 fix(migration): update OpenClaw migration for schema drift
Consolidates fixes from PRs #7869, #7860, #7861, #7862, #7864, #7868.

OpenClaw restructured several internal paths and config schemas that the
migration tool was reading from stale locations:

- workspace/ renamed to workspace-main/ (and workspace-{agentId} for
  multi-agent). source_candidate() now checks fallback paths.
- Channel tokens moved from channels.*.botToken to
  channels.*.accounts.default.botToken. New _get_channel_field() checks
  both flat and accounts.default layout.
- TTS provider 'edge' renamed to 'microsoft'. Migration now checks both
  and normalizes back to 'edge' for Hermes.
- API keys stored in openclaw.json 'env' sub-object (env.<KEY> or
  env.vars.<KEY>) are now discovered as an additional key source.
- Provider apiType values now hyphenated (openai-completions,
  anthropic-messages, google-generative-ai). thinkingDefault expanded
  with minimal, xhigh, adaptive.
- Matrix uses accessToken field, not botToken.
- SecretRef file/exec sources now warn instead of silently skipping.
- Migration notes now mention skills requiring session restart and
  WhatsApp requiring QR re-pairing.

Co-authored-by: SHL0MS <SHL0MS@users.noreply.github.com>
2026-04-11 11:35:23 -07:00
Teknium
976bad5bde refactor(auxiliary): config.yaml takes priority over env vars for aux task settings (#7889)
The auxiliary client previously checked env vars (AUXILIARY_{TASK}_PROVIDER,
AUXILIARY_{TASK}_MODEL, etc.) before config.yaml's auxiliary.{task}.* section.
This violated the project's '.env is for secrets only' policy — these are
behavioral settings, not API keys.

Flipped the resolution order in _resolve_task_provider_model():
  1. Explicit args (always win)
  2. config.yaml auxiliary.{task}.* (PRIMARY)
  3. Env var overrides (backward-compat fallback only)
  4. 'auto' (full auto-detection chain)

Env var reading code is kept for backward compatibility but config.yaml
now takes precedence. Updated module docstring and function docstring.

Also removed AUXILIARY_VISION_MODEL from _EXTRA_ENV_KEYS in config.py.
2026-04-11 11:21:59 -07:00
Teknium
d4bb44d4b9 docs: add Xiaomi MiMo to all provider docs + fix MiMo-V2-Flash ctx len
- environment-variables.md: XIAOMI_API_KEY, XIAOMI_BASE_URL, provider list
- cli-commands.md: --provider choices
- integrations/providers.md: provider table, Chinese providers section,
  config example, base URL list, choosing table, fallback providers list
- fallback-providers.md: supported providers table, auto-detection chain
- Fix XiaomiMiMo/MiMo-V2-Flash context length 32768 → 256000 (OpenRouter entry)
2026-04-11 11:17:52 -07:00
kshitijk4poor
6693e2a497 feat(xiaomi): add Xiaomi MiMo as first-class provider
Cherry-picked from PR #7702 by kshitijk4poor.

Adds Xiaomi MiMo as a direct provider (XIAOMI_API_KEY) with models:
- mimo-v2-pro (1M context), mimo-v2-omni (256K, multimodal), mimo-v2-flash (256K, cheapest)

Standard OpenAI-compatible provider checklist: auth.py, config.py, models.py,
main.py, providers.py, doctor.py, model_normalize.py, model_metadata.py,
models_dev.py, auxiliary_client.py, .env.example, cli-config.yaml.example.

Follow-up: vision tasks use mimo-v2-omni (multimodal) instead of the user's
main model. Non-vision aux uses the user's selected model. Added
_PROVIDER_VISION_MODELS dict for provider-specific vision model overrides.
On failure, falls back to aggregators (gemini flash) via existing fallback chain.

Corrects pre-existing context lengths: mimo-v2-pro 1048576→1000000,
mimo-v2-omni 1048576→256000, adds mimo-v2-flash 256000.

36 tests covering registry, aliases, auto-detect, credentials, models.dev,
normalization, URL mapping, providers module, doctor, aux client, vision
model override, and agent init.
2026-04-11 11:17:52 -07:00
Teknium
55fac8a386 docs: add warning about summary model context length requirement (#7879)
The summary model used for context compaction must have a context window
at least as large as the main agent model. If it's smaller, the
summarization API call fails and middle turns are dropped without a
summary, silently losing conversation context.

Promoted the existing note in configuration.md to a visible warning
admonition, and added a matching warning in the developer guide's
context compression page.
2026-04-11 11:13:48 -07:00
kshitijk4poor
50bb4fe010 fix(vision): auto-resize oversized images, increase default timeout, fix vision capability detection
Cherry-picked from PR #7749 by kshitijk4poor with modifications:

- Raise hard image limit from 5 MB to 20 MB (matches most restrictive provider)
- Send images at full resolution first; only auto-resize to 5 MB on API failure
- Add _is_image_size_error() helper to detect size-related API rejections
- Auto-resize uses Pillow (soft dep) with progressive downscale + JPEG quality reduction
- Fix get_model_capabilities() to check modalities.input for vision support
- Increase default vision timeout from 30s to 120s (matches hardcoded fallback intent)
- Applied retry-with-resize to both vision_analyze_tool and browser_vision

Closes #7740
2026-04-11 11:12:50 -07:00
Teknium
06e1d9cdd4 fix: resolve three high-impact community bugs (#5819, #6893, #3388) (#7881)
Matrix gateway: fix sync loop never dispatching events (#5819)
- _sync_loop() called client.sync() but never called handle_sync()
  to dispatch events to registered callbacks — _on_room_message was
  registered but never fired for new messages
- Store next_batch token from initial sync and pass as since= to
  subsequent incremental syncs (was doing full initial sync every time)
- 17 comments, confirmed by multiple users on matrix.org

Feishu docs: add interactive card configuration for approvals (#6893)
- Error 200340 is a Feishu Developer Console configuration issue,
  not a code bug — users need to enable Interactive Card capability
  and configure Card Request URL
- Added required 3-step setup instructions to feishu.md
- Added troubleshooting entry for error 200340
- 17 comments from Feishu users

Copilot provider drift: detect GPT-5.x Responses API requirement (#3388)
- GPT-5.x models are rejected on /v1/chat/completions by both OpenAI
  and OpenRouter (unsupported_api_for_model error)
- Added _model_requires_responses_api() to detect models needing
  Responses API regardless of provider
- Applied in __init__ (covers OpenRouter primary users) and in
  _try_activate_fallback() (covers Copilot->OpenRouter drift)
- Fixed stale comment claiming gateway creates fresh agents per message
  (it caches them via _agent_cache since the caching was added)
- 7 comments, reported on Copilot+Telegram gateway
2026-04-11 11:12:20 -07:00
Siddharth Balyan
69f3aaa1d6 fix(matrix): pass required args to MemoryCryptoStore for mautrix ≥0.21 (#7848)
* fix(matrix): pass required args to MemoryCryptoStore for mautrix ≥0.21

MemoryCryptoStore.__init__() now requires account_id and pickle_key
positional arguments as of mautrix 0.21. The migration from matrix-nio
(commit 1850747) didn't account for this, causing E2EE initialization
to fail with:

  MemoryCryptoStore.__init__() missing 2 required positional arguments:
  'account_id' and 'pickle_key'

Pass self._user_id as account_id and derive pickle_key from the same
user_id:device_id pair already used for the on-disk HMAC signature.

Update the test stub to accept the new parameters.

Fixes #7803

* fix: use consistent fallback for pickle_key derivation

Address review: _pickle_key now uses _acct_id (which has the 'hermes'
fallback) instead of raw self._user_id, so both values stay consistent
when user_id is empty.

---------

Co-authored-by: Hermes Agent <hermes@nousresearch.com>
2026-04-11 10:43:49 -07:00
Teknium
c94936839c fix: unify openai-codex model list — derive from codex_models.py (#7844)
The _PROVIDER_MODELS['openai-codex'] static list was a manually maintained
duplicate of DEFAULT_CODEX_MODELS in codex_models.py. They drifted — the
static list was missing gpt-5.3-codex-spark (and previously gpt-5.4).

Replace the hardcoded list with _codex_curated_models() which calls
DEFAULT_CODEX_MODELS + _add_forward_compat_models() from codex_models.py.
Now both the CLI 'hermes model' flow and the gateway /model picker derive
from the same source of truth. New models added to DEFAULT_CODEX_MODELS
or _FORWARD_COMPAT_TEMPLATE_MODELS automatically appear everywhere.
2026-04-11 10:38:24 -07:00
Teknium
d7607292d9 fix(streaming): adaptive backoff + cursor strip to prevent message truncation (#7683)
Telegram flood control during streaming caused messages to be cut off
mid-response. The old behavior permanently disabled edits after a single
flood-control failure, losing the remainder of the response.

Changes:
- Adaptive backoff: on flood-control edit failures, double the edit interval
  instead of immediately disabling edits. Only permanently disable after 3
  consecutive failures (_MAX_FLOOD_STRIKES).
- Cursor strip: when entering fallback mode, best-effort edit to remove the
  cursor (▉) from the last visible message so it doesn't appear stuck.
- Fallback send retry: _send_fallback_final retries each chunk once on
  flood-control failures (3s delay) before giving up.
- Default edit_interval increased from 0.3s to 1.0s. Telegram rate-limits
  edits at ~1/s per message; 0.3s was virtually guaranteed to trigger flood
  control on any non-trivial response.
- _send_or_edit returns bool so the overflow split loop knows not to
  truncate accumulated text when an edit fails (prevents content loss).

Fixes: messages cutting/stopping mid-response on Telegram, especially
with streaming enabled.
2026-04-11 10:28:15 -07:00
kshitijk4poor
af9caec44f fix(qwen): correct context lengths for qwen3-coder models and send max_tokens to portal
Based on PR #7285 by @kshitijk4poor.

Two bugs affecting Qwen OAuth users:

1. Wrong context window — qwen3-coder-plus showed 128K instead of 1M.
   Added specific entries before the generic qwen catch-all:
   - qwen3-coder-plus: 1,000,000 (corrected from PR's 1,048,576 per
     official Alibaba Cloud docs and OpenRouter)
   - qwen3-coder: 262,144

2. Random stopping — max_tokens was suppressed for Qwen Portal, so the
   server applied its own low default. Reasoning models exhaust that on
   thinking tokens. Now: honor explicit max_tokens, default to 65536
   when unset.

Co-authored-by: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com>
2026-04-11 03:29:31 -07:00
Teknium
f459214010 feat: background process monitoring — watch_patterns for real-time output alerts
* feat: add watch_patterns to background processes for output monitoring

Adds a new 'watch_patterns' parameter to terminal(background=true) that
lets the agent specify strings to watch for in process output. When a
matching line appears, a notification is queued and injected as a
synthetic message — triggering a new agent turn, similar to
notify_on_complete but mid-process.

Implementation:
- ProcessSession gets watch_patterns field + rate-limit state
- _check_watch_patterns() in ProcessRegistry scans new output chunks
  from all three reader threads (local, PTY, env-poller)
- Rate limited: max 8 notifications per 10s window
- Sustained overload (45s) permanently disables watching for that process
- watch_queue alongside completion_queue, same consumption pattern
- CLI drains watch_queue in both idle loop and post-turn drain
- Gateway drains after agent runs via _inject_watch_notification()
- Checkpoint persistence + crash recovery includes watch_patterns
- Blocked in execute_code sandbox (like other bg params)
- 20 new tests covering matching, rate limiting, overload kill,
  checkpoint persistence, schema, and handler passthrough

Usage:
  terminal(
      command='npm run dev',
      background=true,
      watch_patterns=['ERROR', 'WARN', 'listening on port']
  )

* refactor: merge watch_queue into completion_queue

Unified queue with 'type' field distinguishing 'completion',
'watch_match', and 'watch_disabled' events. Extracted
_format_process_notification() in CLI and gateway to handle
all event types in a single drain loop. Removes duplication
across both CLI drain sites and the gateway.
2026-04-11 03:13:23 -07:00
Hygaard
a2f9f04c06 fix: honor session-scoped gateway model overrides 2026-04-11 03:11:34 -07:00
Teknium
671d5068e7 fix: add gpt-5.4 and gpt-5.4-mini to openai-codex curated model list (#7670)
The _PROVIDER_MODELS['openai-codex'] list was missing gpt-5.4 and gpt-5.4-mini,
causing them to not appear in the /model picker for ChatGPT OAuth users.
codex_models.py already had these models in DEFAULT_CODEX_MODELS, but the
curated list that feeds the Telegram/Discord /model picker was never updated.

Reported by @chongdashu
2026-04-11 03:09:46 -07:00
Fran Fitzpatrick
1a40073a3a fix: enable Matrix Reactions in platform comparison table 2026-04-11 02:58:48 -07:00
jacob-wang
3dd76d2718 docs: fix ASCII diagram width mismatch in architecture.md
The System Overview ASCII diagram had inconsistent box widths:
- Entry Points box bottom border was 73 chars instead of 71

This caused the docs-site-checks CI to fail on every docs-only PR
due to pre-existing errors in the diagram.

Fix: normalize Entry Points bottom border to 71 characters,
matching the top border width.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 02:58:48 -07:00
luyao618
50ad66aee6 test(tools): add unit tests for budget_config module
Cover default constants, BudgetConfig defaults, frozen immutability,
custom construction, and the resolve_threshold() priority chain
(pinned > tool_overrides > registry > default). 20 tests total.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 02:58:48 -07:00
luyao618
80d82c2f5c test(tools): add unit tests for tool_backend_helpers module
Cover all public functions with 50 test cases:
- managed_nous_tools_enabled() feature flag toggling
- normalize_browser_cloud_provider() coercion and defaults
- coerce_modal_mode() / normalize_modal_mode() validation
- has_direct_modal_credentials() env vars and config file detection
- resolve_modal_backend_state() full backend selection matrix
- resolve_openai_audio_api_key() priority chain and edge cases

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 02:58:48 -07:00
Teknium
7241e6134b fix: remove stale test (missing pop_pending), add headers to FakeResponse
Follow-up fixes for cherry-pick conflicts:
- Removed test_context_keeps_pending_approval test that referenced
  pop_pending() which doesn't exist on current main
- Added headers attribute to FakeResponse in vision test (needed
  after #6949 added Content-Length check)
2026-04-11 02:03:20 -07:00
Kenny Xie
ae9a713a0a test(approval): clear leaked bypass state 2026-04-11 02:03:20 -07:00
Kenny Xie
eb8071bbc1 test(gateway): isolate blocking approval env 2026-04-11 02:03:20 -07:00
Kenny Xie
086d92a0e0 test(tools): isolate approval and audio gateway env 2026-04-11 02:03:20 -07:00
Tranquil-Flow
4e56eacdce fix(vision): reject oversized images before API call, handle file:// URIs, improve 400 errors
Three fixes for vision_analyze returning cryptic 400 "Invalid request data":

1. Pre-flight base64 size check — base64 inflates data ~33%, so a 3.8 MB
   file exceeds the 5 MB API limit. Reject early with a clear message
   instead of letting the provider return a generic 400.

2. Handle file:// URIs — strip the scheme and resolve as a local path.
   Previously file:///path/to/image.png fell through to the "invalid
   image source" error since it matched neither is_file() nor http(s).

3. Separate invalid_request errors from "does not support vision" errors
   so the user gets actionable guidance (resize/compress/retry) instead
   of a misleading "model does not support vision" message.

Closes #6677
2026-04-11 02:03:20 -07:00
aaronagent
1909877e6e fix: cap image download size at 50 MB, validate tool call parser fields
vision_tools.py: _download_image() loads the full HTTP response body into
memory via response.content (line 190) with no Content-Length check and no
max file size limit.  An attacker-hosted multi-gigabyte file causes OOM.
Add a 50 MB hard cap: check Content-Length header before download, and
verify actual body size before writing to disk.

hermes_parser.py: tc_data["name"] at line 57 raises KeyError when the LLM
outputs a tool call JSON without a "name" field.  The outer except catches
it silently, causing the entire tool call to be lost with zero diagnostics.
Add "name" field validation before constructing the ChatCompletionMessage.

mistral_parser.py: tc["name"] at line 101 has the same KeyError issue in
the pre-v11 format path.  The fallback decoder (line 112) already checks
"name" correctly, but the primary path does not.  Add validation to match.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 02:03:20 -07:00
aaronagent
307697688e fix: prevent zombie processes, redact cron stderr, skip symlinks in skill enumeration
process_registry.py: _reader_loop() has process.wait() after the try-except
block (line 380).  If the reader thread crashes with an unexpected exception
(e.g. MemoryError, KeyboardInterrupt), control exits the except handler but
skips wait() — leaving the child as a zombie process.  Move wait() and the
cleanup into a finally block so the child is always reaped.

cron/scheduler.py: _run_job_script() only redacts secrets in stdout on the
SUCCESS path (line 417-421).  When a cron script fails (non-zero exit), both
stdout and stderr are returned WITHOUT redaction (lines 407-413).  A script
that accidentally prints an API key to stderr during a failure would leak it
into the LLM context.  Move redaction before the success/failure branch so
both paths benefit.

skill_commands.py: _build_skill_message() enumerates supporting files using
rglob("*") but only checks is_file() (line 171) without filtering symlinks.
PR #6693 added symlink protection to scan_skill_commands() but missed this
function.  A malicious skill can create symlinks in references/ pointing to
arbitrary files, exposing their paths (and potentially content via skill_view)
to the LLM.  Add is_symlink() check to match the guard in scan_skill_commands.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 02:03:20 -07:00
kagura-agent
4d1f1dccf9 fix: normalize numeric MCP server names to str (fixes #6901)
YAML parses bare numeric keys (e.g. `12306:`) as int, causing
TypeError when sorted() is called on mixed int/str collections.

Changes:
- Normalize toolset_names entries to str in _get_platform_tools()
- Cast MCP server name to str(name) when building enabled_mcp_servers
- Add regression test
2026-04-11 02:03:20 -07:00
jjovalle99
640441b865 feat(tools): add Voxtral TTS provider (Mistral AI) 2026-04-11 01:56:55 -07:00
Teknium
5a55d54ee2 fix(gateway): don't suppress error messages when streaming already_sent (#7652)
When the stream consumer has sent at least one message (already_sent=True),
the gateway skips sending the final response to avoid duplicates. But this
also suppressed error messages when the agent failed mid-loop — rate limit
exhaustion, context overflow, compression failure, etc.

The user would see the last streamed content and then nothing: no error
message, no explanation. The agent appeared to 'stop responding.'

Fix: check the 'failed' flag at both the producer (_run_agent marks
already_sent) and consumer (_handle_message_with_agent checks it) sites.
Error messages are always delivered regardless of streaming state.
2026-04-11 01:55:36 -07:00
Teknium
424b62aa16 fix: update async fallback test mock to 5-tuple for api_mode 2026-04-11 01:52:58 -07:00
kshitijk4poor
c89719ad9c fix: warn and clear stale OPENAI_BASE_URL on provider switch (#5161) 2026-04-11 01:52:58 -07:00
kshitijk4poor
d3c5d65563 fix(auxiliary): validate response shape in call_llm/async_call_llm (#7264)
async_call_llm (and call_llm) can return non-OpenAI objects from
custom providers or adapter shims, crashing downstream consumers
with misleading AttributeError ('str' has no attribute 'choices').

Add _validate_llm_response() that checks the response has the
expected .choices[0].message shape before returning. Wraps all
return paths in call_llm, async_call_llm, and fallback paths.
Fails fast with a clear RuntimeError identifying the task, response
type, and a preview of the malformed payload.

Closes #7264
2026-04-11 01:52:58 -07:00
ran
4f5e8b22a7 fix: drop incompatible model slugs on auxiliary client cache hit
`resolve_provider_client()` already drops OpenRouter-format model slugs
(containing "/") when the resolved provider is not OpenRouter (line 1097).
However, `_get_cached_client()` returns `model or cached_default` directly
on cache hits, bypassing this check entirely.

When the main provider is openai-codex, the auto-detection chain (Step 1
of `_resolve_auto`) caches a CodexAuxiliaryClient. Subsequent auxiliary
calls for different tasks (e.g. compression with `summary_model:
google/gemini-3-flash-preview`) hit the cache and pass the OpenRouter-
format model slug straight to the Codex Responses API, which does not
understand it and returns an empty `response.output`.

This causes two user-visible failures:
- "Invalid API response shape" (empty output after 3 retries)
- "Context length exceeded, cannot compress further" (compression itself
  fails through the same path)

Add `_compat_model()` helper that mirrors the "/" check from
`resolve_provider_client()` and call it on the cache-hit return path.
2026-04-11 01:52:58 -07:00
kshitijk4poor
eeb8b4b00f fix(auxiliary): harden fallback behavior for non-OpenRouter users
Four fixes to auxiliary_client.py:

1. Respect explicit provider as hard constraint (#7559)
   When auxiliary.{task}.provider is explicitly set (not 'auto'),
   connection/payment errors no longer silently fallback to cloud
   providers. Local-only users (Ollama, vLLM) will no longer get
   unexpected OpenRouter billing from auxiliary tasks.

2. Eliminate model='default' sentinel (#7512)
   _resolve_api_key_provider() no longer sends literal 'default' as
   model name to APIs. Providers without a known aux model in
   _API_KEY_PROVIDER_AUX_MODELS are skipped instead of producing
   model_not_supported errors.

3. Add payment/connection fallback to async_call_llm (#7512)
   async_call_llm now mirrors sync call_llm's fallback logic for
   payment (402) and connection errors. Previously, async consumers
   (session_search, web_tools, vision) got hard failures with no
   recovery. Also fixes hardcoded 'openrouter' fallback to use the
   full auto-detection chain.

4. Use accurate error reason in fallback logs (#7512)
   _try_payment_fallback() now accepts a reason parameter and uses
   it in log messages. Connection timeouts are no longer misleadingly
   logged as 'payment error'.

Closes #7559
Closes #7512
2026-04-11 01:52:58 -07:00
kshitijk4poor
ffbd80f5fc fix(auxiliary): honor api_mode in auxiliary client (#6800)
The auxiliary client always calls client.chat.completions.create(),
ignoring the api_mode config flag. This breaks codex-family models
(e.g. gpt-5.3-codex) on direct OpenAI API keys, which need the
/v1/responses endpoint.

Changes:
- Expand _resolve_task_provider_model to return api_mode (5-tuple)
- Read api_mode from auxiliary.{task}.api_mode config and env vars
  (AUXILIARY_{TASK}_API_MODE)
- Pass api_mode through _get_cached_client to resolve_provider_client
- Add _needs_codex_wrap/_wrap_if_needed helpers that wrap plain OpenAI
  clients in CodexAuxiliaryClient when api_mode=codex_responses or
  when auto-detection finds api.openai.com + codex model pattern
- Apply wrapping at all custom endpoint, named custom provider, and
  API-key provider return paths
- Update test mocks for the new 5-tuple return format

Users can now set:
  auxiliary:
    compression:
      model: gpt-5.3-codex
      base_url: https://api.openai.com/v1
      api_mode: codex_responses

Closes #6800
2026-04-11 01:52:58 -07:00
Long Hao
58b62e3e43 feat(skin): make all CLI colors skin-aware
Refactor hardcoded color constants throughout the CLI to resolve from
the active skin engine, so custom themes fully control the visual
appearance.

cli.py:
- Replace _GOLD constant with _ACCENT (_SkinAwareAnsi class) that
  lazily resolves response_border from the active skin
- Rename _GOLD_DEFAULT to _ACCENT_ANSI_DEFAULT
- Make _build_compact_banner() read banner_title/accent/dim from skin
- Make session resume notifications use _accent_hex()
- Make status line use skin colors (accent_color, separator_color,
  label_color instead of cryptic _dim_c/_dim_c2/_accent_c/_label_c)
- Reset _ACCENT cache on /skin switch

agent/display.py:
- Replace hardcoded diff ANSI escapes with skin-aware functions:
  _diff_dim(), _diff_file(), _diff_hunk(), _diff_minus(), _diff_plus()
  (renamed from SCREAMING_CASE _ANSI_* to snake_case)
- Add reset_diff_colors() for cache invalidation on skin switch
2026-04-11 01:47:48 -07:00
jamesarch
704488b207 fix(setup): relaunch chat in a fresh process 2026-04-11 01:47:48 -07:00
Jerome Xu
3065e69dc5 fix(docker): install procps in Docker image (#7032)
Adds procps to apt-get install in Dockerfile, enabling ps/pgrep/pkill inside the container. Contributed by @HiddenPuppy.
2026-04-11 01:22:07 -07:00
konsisumer
b87e0f59cc fix(skills): read name from SKILL.md frontmatter in skills_sync
_discover_bundled_skills() used the directory name to identify skills,
but skills_tool.py and skills_hub.py use the `name:` field from SKILL.md
frontmatter.  This mismatch caused 9 builtin skills whose directory name
differs from their SKILL.md name to be written to .bundled_manifest
under the wrong key, so `hermes skills list` showed them as "local"
instead of "builtin".

Read the frontmatter name field (with directory-name fallback) so the
manifest keys match what the rest of the codebase expects.

Closes #6835
2026-04-11 01:21:20 -07:00
kshitijk4poor
d442f25a2f fix: align MiniMax provider with official API docs
Aligns MiniMax provider with official API documentation. Fixes 6 bugs:
transport mismatch (openai_chat -> anthropic_messages), credential leak
in switch_model(), prompt caching sent to non-Anthropic endpoints,
dot-to-hyphen model name corruption, trajectory compressor URL routing,
and stale doctor health check.

Also corrects context window (204,800), thinking support (manual mode),
max output (131,072), and model catalog (M2 family only on /anthropic).

Source: https://platform.minimax.io/docs/api-reference/text-anthropic-api

Co-authored-by: kshitijk4poor <kshitijk4poor@users.noreply.github.com>
2026-04-11 01:04:41 -07:00
Kathie1ee
d9f53dba4c feat(honcho): add opt-in initOnSessionStart for tools mode and respect explicit peerName (#6995)
Two fixes for the honcho memory plugin: (1) initOnSessionStart — opt-in eager session init in tools mode so sync_turn() works from turn 1 (default false, non-breaking). (2) peerName fix — gateway user_id no longer silently overwrites an explicitly configured peerName. 11 new tests. Contributed by @Kathie-yu.
2026-04-11 00:43:27 -07:00
Moris Chao
5b16f31702 feat(plugins): pass sender_id to pre_llm_call hook
The pre_llm_call plugin hook receives session_id, user_message,
conversation_history, is_first_turn, model, and platform — but not
the sender's user_id. This means plugins cannot perform per-user
access control (e.g. restricting knowledge base recall to authorized
users).

The gateway already passes source.user_id as user_id to AIAgent,
which stores it in self._user_id. This change forwards it as
sender_id in the pre_llm_call kwargs so plugins can use it for
ACL decisions.

For CLI sessions where no user_id exists, sender_id defaults to
empty string. Plugins can treat empty sender_id as a trusted local
call (the owner is at the terminal) or deny it depending on their
ACL policy.
2026-04-11 00:43:20 -07:00
Teknium
caf371da18 fix: MiniMax/Alibaba incorrectly detected as Anthropic OAuth, causing mcp_ tool prefix (#7509)
_is_oauth_token() returned True for any key not starting with 'sk-ant-api',
which means MiniMax and Alibaba API keys were falsely treated as Anthropic
OAuth tokens. This triggered the Claude Code compatibility path:
- All tool names prefixed with mcp_ (e.g. mcp_terminal, mcp_web_search)
- System prompt injected with 'You are Claude Code' identity
- 'Hermes Agent' replaced with 'Claude Code' throughout

Fix: Make _is_oauth_token() positively identify Anthropic OAuth tokens by
their key format instead of using a broad catch-all:
- sk-ant-* (but not sk-ant-api-*) -> setup tokens, managed keys
- eyJ* -> JWTs from Anthropic OAuth flow
- Everything else -> False (MiniMax, Alibaba, etc.)

Reported by stefan171.
2026-04-11 00:43:01 -07:00
SHL0MS
e902e55b26 Merge pull request #7555 from SHL0MS/feat/creative-ideation-skill
feat(skills): add creative ideation — constraint-driven project generation
2026-04-11 02:09:17 -04:00
SHL0MS
801a26c014 feat(skills): add creative ideation — constraint-driven project generation
Generate project ideas through creative constraints. Constraint + direction
= creativity.

Core skill (SKILL.md, 147 lines):
- 15 curated constraints across 3 categories: developers, makers, anyone
- Developer-focused prompts: 'solve your own itch', 'the CLI tool that
  should exist', 'automate the annoying thing', 'nothing new except glue'
- Matching table: maps user mood/intent to appropriate constraints
- Complete worked example with 3 concrete project ideas
- Output format for consistent, actionable idea presentation

Extended library (references/full-prompt-library.md, 110 lines):
- 30+ additional constraints: communication, screens, philosophy,
  transformation, identity, scale, starting points

Constraint approach inspired by wttdotm.com/prompts.html. Adapted for
software development and general-purpose ideation.
2026-04-11 01:44:36 -04:00
SHL0MS
939d2b37d1 Merge pull request #6882 from SHL0MS/feat/creative-divergence-strategies
feat(skills): add creative divergence strategies for experimental output
2026-04-11 01:21:47 -04:00
Teknium
9605195575 fix: restore agent.close() cleanup and correct /restart category
- Add agent.close() call to _finalize_shutdown_agents() to prevent
  zombie processes (terminal sandboxes, browser daemons, httpx clients)
- Global cleanup (process_registry, environments, browsers) preserved
  in _stop_impl() during conflict resolution
- Move /restart CommandDef from 'Info' to 'Session' category to match
  /stop and /status
2026-04-10 21:18:34 -07:00
Kenny Xie
ecfae98152 fix(gateway): address restart review feedback 2026-04-10 21:18:34 -07:00
aquaright1
a55c044ca8 fix(gateway): self-request service restarts when invoked in-process 2026-04-10 21:18:34 -07:00
Kenny Xie
c4ccb320cd fix(gateway): tolerate partial runner construction 2026-04-10 21:18:34 -07:00
Kenny Xie
3163731289 fix(gateway): drain in-flight work before restart 2026-04-10 21:18:34 -07:00
Teknium
241032455c fix: don't evict cached agent on failed runs — prevents MCP restart loop (#7539)
* fix: circuit breaker stops CPU-burning restart loops on persistent errors

When a gateway session hits a non-retryable error (e.g. invalid model
ID → HTTP 400), the agent fails and returns. But if the session keeps
receiving messages (or something periodically recreates agents), each
attempt spawns a new AIAgent — reinitializing MCP server connections,
burning CPU — only to hit the same 400 error again. On a 4-core server,
this pegs an entire core per stuck session and accumulates 300+ minutes
of CPU time over hours.

Fix: add a per-session consecutive failure counter in the gateway runner.

- Track consecutive non-retryable failures per session key
- After 3 consecutive failures (_MAX_CONSECUTIVE_FAILURES), block
  further agent creation for that session and notify the user:
  '⚠️ This session has failed N times in a row with a non-retryable
  error. Use /reset to start a new session.'
- Evict the cached agent when the circuit breaker engages to prevent
  stale state from accumulating
- Reset the counter on successful agent runs
- Clear the counter on /reset and /new so users can recover
- Uses getattr() pattern so bare GatewayRunner instances (common in
  tests using object.__new__) don't crash

Tests:
- 8 new tests in test_circuit_breaker.py covering counter behavior,
  threshold, reset, session isolation, and bare-runner safety

Addresses #7130.

* Revert "fix: circuit breaker stops CPU-burning restart loops on persistent errors"

This reverts commit d848ea7109d62a2fc4ba6da36fc4f0366b5ded94.

* fix: don't evict cached agent on failed runs — prevents MCP restart loop

When a run fails (e.g. invalid model ID → 400) and fallback activated,
the gateway was evicting the cached agent to 'retry primary next time.'
But evicting a failed agent forces a full AIAgent recreation on the next
message — reinitializing MCP server connections, spawning stdio
processes — only to hit the same 400 again. This created a CPU-burning
loop (91%+ for hours, #7130).

The fix: add `and not _run_failed` to the fallback-eviction check.
Failed runs keep the cached agent. The next message reuses it (no MCP
reinit), hits the same error, returns it to the user quickly. The user
can /reset or /model to fix their config.

Successful fallback runs still evict as before so the next message
retries the primary model.

Addresses #7130.
2026-04-10 21:16:56 -07:00
Kenny Xie
1ffd92cc94 fix(gateway): make manual compression feedback truthful 2026-04-10 21:16:53 -07:00
Kenny Xie
d6c2ad7e41 fix(gateway): make compress responses truthful 2026-04-10 21:16:53 -07:00
luyao618
fc06a0147e fix(tools): remove dead code in _is_likely_binary and harden _check_lint against brace paths
- Remove unreachable `if not content_sample` branch inside the truthy
  `if content_sample` block in `_is_likely_binary()` (dead code that
  could never execute).
- Replace `linter_cmd.format(file=...)` with `linter_cmd.replace("{file}", ...)`
  in `_check_lint()` so file paths containing curly braces (e.g.
  `src/{test}.py`) no longer raise KeyError/ValueError.
- Add 16 unit tests covering both fixes and edge cases.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 21:16:53 -07:00
hermes-agent-dhabibi
c1af614289 fix: wrap copilot Responses-API models in CodexAuxiliaryClient for auxiliary tasks
GPT-5+ models (except gpt-5-mini) are only accessible via the Responses
API on Copilot. When these models were configured as the compression
summary_model (or any auxiliary task), the plain OpenAI client sent them
to /chat/completions which returned a 400 error:

    model "gpt-5.4-mini" is not accessible via the /chat/completions endpoint

resolve_provider_client() now checks _should_use_copilot_responses_api()
for the copilot provider and wraps the client in CodexAuxiliaryClient
when needed, routing calls through responses.stream() transparently.

Adds tests for both the wrapping (gpt-5.4-mini) and non-wrapping
(gpt-4.1-mini) paths.
2026-04-10 21:16:53 -07:00
hermes-agent-dhabibi
718e8ad6fa feat(delegation): add configurable reasoning_effort for subagents
Add delegation.reasoning_effort config key so subagents can run at a
different thinking level than the parent agent. When set, overrides
the parent's reasoning_config; when empty, inherits as before.

Valid values: xhigh, high, medium, low, minimal, none (disables thinking).

Config path: delegation.reasoning_effort in config.yaml

Files changed:
- tools/delegate_tool.py: resolve override in _build_child_agent
- hermes_cli/config.py: add reasoning_effort to DEFAULT_CONFIG
- tests/tools/test_delegate.py: 4 new tests covering all cases
2026-04-10 21:16:53 -07:00
Teknium
be9198f1e1 fix: guard mautrix imports for gateway-safe fallback + fix test isolation
Follow-up fixes for the matrix-nio → mautrix migration:

1. Module-level mautrix.types import now wrapped in try/except with
   proper stub classes. Without this, importing gateway.platforms.matrix
   crashes the entire gateway when mautrix isn't installed — even for
   users who don't use Matrix. The stubs mirror mautrix's real attribute
   names so tests that exercise adapter methods (send, reactions, etc.)
   work without the real SDK.

2. Removed _ensure_mautrix_mock() from test_matrix_mention.py — it
   permanently installed MagicMock modules in sys.modules via setdefault(),
   polluting later tests in the suite. No longer needed since the module
   imports cleanly without mautrix.

3. Fixed thread persistence tests to use direct class reference in
   monkeypatch.setattr() instead of string-based paths, which broke
   when the module was reimported by other tests.

4. Moved the module-importability test to a subprocess to prevent it
   from polluting sys.modules (reimporting creates a second module object
   with different __dict__, breaking patch.object in subsequent tests).
2026-04-10 21:15:59 -07:00
alt-glitch
be06db71d7 fix(matrix): ignore m.notice messages to prevent bot-to-bot loops
The old nio code only handled RoomMessageText (m.text). The mautrix
rewrite dispatched both m.text and m.notice, which would cause infinite
loops between bots since m.notice is the conventional msgtype for bot
responses in the Matrix ecosystem.
2026-04-10 21:15:59 -07:00
alt-glitch
5d3332dbba fix(matrix): close leaked sessions on connect failure + HMAC-sign pickle store
- Add api.session.close() on E2EE dep check and E2EE setup failure
  paths (two missing cleanup points from the mautrix migration)
- Replace raw pickle.load/dump with HMAC-SHA256 signed payloads to
  prevent arbitrary code execution from a tampered store file
2026-04-10 21:15:59 -07:00
alt-glitch
bc8b93812c refactor(matrix): simplify adapter after code review
- Extract _resolve_message_context() to deduplicate ~40 lines of
  mention/thread/DM gating logic between text and media handlers
- Move mautrix.types imports to module level (16 scattered local
  imports consolidated)
- Parse mention/thread env vars once in __init__ instead of per-message
- Cache _is_bot_mentioned() result instead of calling 3x per event
- Consolidate send_emote/send_notice into shared _send_simple_message()
- Use _is_dm_room() in get_chat_info() instead of inline duplication
- Add _CRYPTO_PICKLE_PATH constant (was duplicated in 2 locations)
- Fix fragile event_ts extraction (double getattr, None safety)
- Clean up leaked aiohttp session on auth failure paths
- Remove redundant trailing _track_thread() calls
2026-04-10 21:15:59 -07:00
alt-glitch
1f3f120042 fix(matrix): persist E2EE crypto store and fix decrypted event dedup
Address two bugs found by code review:

1. MemoryCryptoStore loses all E2EE keys on restart — now pickle the
   store to disk on disconnect and restore on connect, preserving
   Megolm sessions across restarts.

2. Encrypted events buffered for retry were silently dropped after
   decryption because _on_encrypted_event registered the event ID
   in the dedup set, then _on_room_message rejected it as a
   duplicate. Now clear the dedup entry before routing decrypted
   events.
2026-04-10 21:15:59 -07:00
alt-glitch
d5be23aed7 docs(matrix): update all references from matrix-nio to mautrix 2026-04-10 21:15:59 -07:00
alt-glitch
417e28f941 test(matrix): update all test mocks for mautrix-python API
Rewrite mock infrastructure across three test files:
- test_matrix.py: replace fake nio module with fake mautrix module tree,
  update all client method mocks to new API names and return types
- test_matrix_voice.py: update event construction, download/upload mocks,
  handler invocation (single event arg, no room object)
- test_matrix_mention.py: update mock module, event construction, DM
  detection via _dm_rooms cache instead of room.member_count

157 tests passing.
2026-04-10 21:15:59 -07:00
alt-glitch
8053d48c8d refactor(matrix): rewrite adapter from matrix-nio to mautrix-python
Translate all nio SDK calls to mautrix equivalents while preserving the
adapter structure, business logic, and all features (E2EE, reactions,
threading, mention gating, text batching, media caching, voice MSC3245).

Key changes:
- nio.AsyncClient -> mautrix.client.Client + HTTPAPI + MemoryStateStore
- Manual E2EE key management -> OlmMachine with auto key lifecycle
- isinstance(resp, nio.XxxResponse) -> mautrix returns values directly
- add_event_callback per type -> single ROOM_MESSAGE handler with
  msgtype dispatch
- Room state (member_count, display_name) via async state store lookups
- Upload/download return ContentURI/bytes directly (no wrapper objects)
2026-04-10 21:15:59 -07:00
alt-glitch
1850747172 refactor(matrix): swap matrix-nio for mautrix-python dependency
matrix-nio pulls in peewee -> atomicwrites (sdist-only, archived,
missing build-system metadata) which breaks nix flake builds.
mautrix-python publishes wheels, has a leaner dep tree, and its
[encryption] extra uses the same python-olm without the problematic
transitive chain.
2026-04-10 21:15:59 -07:00
Teknium
a8fd7257b1 feat(gateway): WSL-aware gateway with smart systemd detection (#7510)
- Add shared is_wsl() to hermes_constants (like is_termux)
- Update supports_systemd_services() to verify systemd is actually
  running on WSL before returning True
- Add WSL-specific guidance in gateway install/start/setup/status
  for both cases: WSL+systemd and WSL without systemd
- Improve help strings: 'run' now says recommended for WSL/Docker,
  'start'/'install' now mention systemd/launchd explicitly
- Add WSL gateway FAQ section with tmux/nohup/Task Scheduler tips
- Update CLI commands docs with WSL tip
- Deduplicate _is_wsl() from clipboard.py to shared hermes_constants
- Fix clipboard tests to reset hermes_constants cache
- 20 new WSL-specific tests covering detection, systemd check,
  supports_systemd_services integration, and command output

Motivated by user feedback: took 1 hour to figure out run vs start
on WSL, Telegram bot kept disconnecting due to flaky WSL systemd.
2026-04-10 21:15:47 -07:00
Hermes Agent
830040f937 fix: remove unused BulkUploadFn import from daytona.py 2026-04-10 21:14:32 -07:00
Hermes Agent
97bb64dbbf test(file_sync): add tests for bulk_upload_fn callback
Cover the three key behaviors:
- bulk_upload_fn is called instead of per-file upload_fn
- Fallback to upload_fn when bulk_upload_fn is None
- Rollback on bulk upload failure retries all files
2026-04-10 21:14:32 -07:00
Hermes Agent
223a0623ee fix(daytona): use logger.warning instead of warnings.warn for disk cap
warnings.warn() is suppressed/invisible when running as a gateway
or agent. Switch to logger.warning() so the disk cap message
actually appears in logs.

Fixes #7362 (item 3).
2026-04-10 21:14:32 -07:00
Hermes Agent
ac30abd89e fix(config): bridge container resource settings to env vars
Add terminal.container_cpu, container_memory, container_disk, and
container_persistent to the _config_to_env_sync dict so that
`hermes config set terminal.container_memory 8192` correctly
writes TERMINAL_CONTAINER_MEMORY=8192 to ~/.hermes/.env.

Previously these YAML keys had no effect because terminal_tool.py
reads only env vars and the bridge was missing these mappings.

Fixes #7362 (item 2).
2026-04-10 21:14:32 -07:00
Hermes Agent
bff64858f9 perf(daytona): bulk upload files in single HTTP call
FileSyncManager now accepts an optional bulk_upload_fn callback.
When provided, all changed files are uploaded in one call instead
of iterating one-by-one with individual HTTP POSTs.

DaytonaEnvironment wires this to sandbox.fs.upload_files() which
batches everything into a single multipart POST — ~580 files goes
from ~5 min to <2s on init.

Parent directories are pre-created in one mkdir -p call.

Fixes #7362 (item 1).
2026-04-10 21:14:32 -07:00
Teknium
79198eb3a0 docs: context engine plugin system + unified hermes plugins UI
New page:
- developer-guide/context-engine-plugin.md — full guide for building
  context engine plugins (ABC contract, lifecycle, tools, registration)

Updated pages (11 files):
- plugins.md — plugin types table, composite UI documentation with
  screenshot-style example, provider plugin config format
- cli-commands.md — hermes plugins section rewritten for composite UI
  with provider plugin config keys documented
- context-compression-and-caching.md — new 'Pluggable Context Engine'
  section explaining the ABC, config-driven selection, resolution order
- configuration.md — new 'Context Engine' config section with examples
- architecture.md — context_engine.py and plugins/context_engine/ added
  to directory trees, plugin system description updated
- memory-provider-plugin.md — cross-reference tip to context engines
- memory-providers.md — hermes plugins as alternative setup path
- agent-loop.md — context_engine.py added to file reference table
- overview.md — plugins description expanded to cover all 3 types
- build-a-hermes-plugin.md — tip box linking to specialized plugin guides
- sidebars.ts — context-engine-plugin added to Extending category
2026-04-10 19:15:50 -07:00
Teknium
436dfd5ab5 fix: no auto-activation + unified hermes plugins UI with provider categories
- Remove auto-activation: when context.engine is 'compressor' (default),
  plugin-registered engines are NOT used. Users must explicitly set
  context.engine to a plugin name to activate it.

- Add curses_radiolist() to curses_ui.py: single-select radio picker
  with keyboard nav + text fallback, matching curses_checklist pattern.

- Rewrite cmd_toggle() as composite plugins UI:
  Top section: general plugins with checkboxes (existing behavior)
  Bottom section: provider plugin categories (Memory Provider, Context Engine)
  with current selection shown inline. ENTER/SPACE on a category opens
  a radiolist sub-screen for single-select configuration.

- Add provider discovery helpers: _discover_memory_providers(),
  _discover_context_engines(), config read/save for memory.provider
  and context.engine.

- Add tests: radiolist non-TTY fallback, provider config save/load,
  discovery error handling, auto-activation removal verification.
2026-04-10 19:15:50 -07:00
Teknium
3fe6938176 fix: robust context engine interface — config selection, plugin discovery, ABC completeness
Follow-up fixes for the context engine plugin slot (PR #5700):

- Enhance ContextEngine ABC: add threshold_percent, protect_first_n,
  protect_last_n as class attributes; complete update_model() default
  with threshold recalculation; clarify on_session_end() lifecycle docs
- Add ContextCompressor.update_model() override for model/provider/
  base_url/api_key updates
- Replace all direct compressor internal access in run_agent.py with
  ABC interface: switch_model(), fallback restore, context probing
  all use update_model() now; _context_probed guarded with getattr/
  hasattr for plugin engine compatibility
- Create plugins/context_engine/ directory with discovery module
  (mirrors plugins/memory/ pattern) — discover_context_engines(),
  load_context_engine()
- Add context.engine config key to DEFAULT_CONFIG (default: compressor)
- Config-driven engine selection in run_agent.__init__: checks config,
  then plugins/context_engine/<name>/, then general plugin system,
  falls back to built-in ContextCompressor
- Wire on_session_end() in shutdown_memory_provider() at real session
  boundaries (CLI exit, /reset, gateway expiry)
2026-04-10 19:15:50 -07:00
Stephen Schoettler
5d8dd622bc feat: wire context engine tools, session lifecycle, and tool dispatch
- Inject engine tool schemas into agent tool surface after compressor init
- Call on_session_start() with session_id, hermes_home, platform, model
- Dispatch engine tool calls (lcm_grep, etc.) before regular tool handler
- 55/55 tests pass
2026-04-10 19:15:50 -07:00
Stephen Schoettler
92382fb00e feat: wire context engine plugin slot into agent and plugin system
- PluginContext.register_context_engine() lets plugins replace the
  built-in ContextCompressor with a custom ContextEngine implementation
- PluginManager stores the registered engine; only one allowed
- run_agent.py checks for a plugin engine at init before falling back
  to the default ContextCompressor
- reset_session_state() now calls engine.on_session_reset() instead of
  poking internal attributes directly
- ContextCompressor.on_session_reset() handles its own internals
  (_context_probed, _previous_summary, etc.)
- 19 new tests covering ABC contract, defaults, plugin slot registration,
  rejection of duplicates/non-engines, and compressor reset behavior
- All 34 existing compressor tests pass unchanged
2026-04-10 19:15:50 -07:00
Stephen Schoettler
fe7e6c156c feat: add ContextEngine ABC, refactor ContextCompressor to inherit from it
Introduces agent/context_engine.py — an abstract base class that defines
the pluggable context engine interface. ContextCompressor now inherits
from ContextEngine as the default implementation.

No behavior change. All 34 existing compressor tests pass.

This is the foundation for a context engine plugin slot, enabling
third-party engines like LCM (Lossless Context Management) to replace
the built-in compressor via the plugin system.
2026-04-10 19:15:50 -07:00
Teknium
842e669a13 fix: activate fallback provider on repeated empty responses + user-visible status (#7505)
When models return empty responses (no content, no tool calls, no
reasoning), Hermes previously retried 3 times silently then fell through
to '(empty)' — without ever trying the fallback provider chain. Users on
GLM-4.5-Air and similar models experienced what appeared to be a
complete hang, especially in gateway (Telegram/Discord) contexts where
the silent retries produced zero feedback.

Changes:
- After exhausting 3 empty retries, attempt _try_activate_fallback()
  before giving up with '(empty)'. If fallback succeeds, reset retry
  counter and continue the conversation loop with the new provider.
- Replace all _vprint() calls in recovery paths with _emit_status(),
  which surfaces messages through both CLI (_vprint with force=True)
  and gateway (status_callback -> adapter.send). Users now see:
  * '⚠️ Empty response from model — retrying (N/3)' during retries
  * '⚠️ Model returning empty responses — switching to fallback...'
  * '↻ Switched to fallback: <model> (<provider>)' on success
  * ' Model returned no content after all retries [and fallback]'
- Add logger.warning() throughout empty response paths for log file
  visibility (model name, provider, retry counts).
- Upgrade _last_content_with_tools fallback from logger.debug to
  logger.info + _emit_status so recovery is visible.
- Upgrade thinking-only prefill continuation to use _emit_status.

Tests:
- test_empty_response_triggers_fallback_provider: verifies fallback
  activation after 3 empty retries produces content from fallback model
- test_empty_response_fallback_also_empty_returns_empty: verifies
  graceful degradation when fallback also returns empty
- test_empty_response_emits_status_for_gateway: verifies _emit_status
  is called during retries so gateway users see feedback

Addresses #7180.
2026-04-10 19:15:41 -07:00
Bartok Moltbot
992422910c fix(api): send tool progress as custom SSE event to prevent model corruption (#6972)
Tool progress markers (e.g. ` list`) were injected directly into
SSE delta.content chunks. OpenAI-compatible frontends (Open WebUI,
LobeChat, etc.) store delta.content verbatim as the assistant message
and send it back on subsequent requests. After enough turns, the model
learns to emit these markers as plain text instead of issuing real tool
calls — silently hallucinating tool results without ever running them.

Fix: Send tool progress as a custom `event: hermes.tool.progress` SSE
event instead of mixing it into delta.content. Per the SSE spec, clients
that don't understand a custom event type silently ignore it, so this is
backward-compatible. Frontends that want to render progress indicators
can listen for the custom event without persisting it to conversation
history.

The /v1/runs endpoint already uses structured events — this aligns the
/v1/chat/completions streaming path with the same principle.

Closes #6972
2026-04-10 18:55:26 -07:00
Siddharth Balyan
9a0c44f908 fix(nix): gate matrix extra to Linux in [all] profile (#7461)
* fix(nix): gate matrix extra to Linux in [all] profile

matrix-nio[e2e] depends on python-olm which is upstream-broken on modern
macOS (Clang 21+, archived libolm). Previously the [matrix] extra was
completely excluded from [all], meaning NixOS users (who install via [all])
had no Matrix support at all.

Add a sys_platform == 'linux' marker so [all] pulls in [matrix] on Linux
(where python-olm builds fine) while still skipping it on macOS. This
fixes the NixOS setup path without breaking macOS installs.

Update the regression test to verify the Linux-gated marker is present
rather than just checking matrix is absent from [all].

Fixes #4594

* chore: regenerate uv.lock with matrix-on-linux in [all]
2026-04-11 05:59:56 +05:30
Teknium
baddb6f717 fix(gateway): derive channel directory platforms from enum instead of hardcoded list (#7450)
Six platforms (matrix, mattermost, dingtalk, feishu, wecom, homeassistant)
were missing from the session-based discovery loop, causing /channels and
send_message to return empty results on those platforms.

Instead of adding them to the hardcoded tuple (which would break again when
new platforms are added), derive the list dynamically from the Platform enum.
Only infrastructure entries (local, api_server, webhook) are excluded;
Discord and Slack are skipped automatically because their direct builders
already populate the platforms dict.

Reported by sprmn24 in PR #7416.
2026-04-10 17:27:32 -07:00
0xFrank-eth
e8034e2f6a fix(gateway): replace os.environ session state with contextvars for concurrency safety
When two gateway messages arrived concurrently, _set_session_env wrote
HERMES_SESSION_PLATFORM/CHAT_ID/CHAT_NAME/THREAD_ID into the process-global
os.environ. Because asyncio tasks share the same process, Message B would
overwrite Message A's values mid-flight, causing background-task notifications
and tool calls to route to the wrong thread/chat.

Replace os.environ with Python's contextvars.ContextVar. Each asyncio task
(and any run_in_executor thread it spawns) gets its own copy, so concurrent
messages never interfere.

Changes:
- New gateway/session_context.py with ContextVar definitions, set/clear/get
  helpers, and os.environ fallback for CLI/cron/test backward compatibility
- gateway/run.py: _set_session_env returns reset tokens, _clear_session_env
  accepts them for proper cleanup in finally blocks
- All tool consumers updated: cronjob_tools, send_message_tool, skills_tool,
  terminal_tool (both notify_on_complete AND check_interval blocks), tts_tool,
  agent/skill_utils, agent/prompt_builder
- Tests updated for new contextvar-based API

Fixes #7358

Co-authored-by: teknium1 <127238744+teknium1@users.noreply.github.com>
2026-04-10 17:04:38 -07:00
Dylan Socolobsky
dab5ec8245 test(e2e): add Slack to parametrized e2e platform tests 2026-04-10 16:51:44 -07:00
Dylan Socolobsky
79565630b0 refactor(e2e): unify Telegram and Discord e2e tests into parametrized platform fixtures 2026-04-10 16:51:44 -07:00
Dylan Socolobsky
7033dbf5d6 test(e2e): add Discord e2e integration tests 2026-04-10 16:51:44 -07:00
pefontana
9555a0cf31 fix(gateway): look up expired agents in _agent_cache, add global kill_all
Two fixes from PR review:

1. Session expiry was looking in _running_agents for the cached agent,
   but idle expired sessions live in _agent_cache. Now checks
   _agent_cache first, falls back to _running_agents.

2. Global cleanup in stop() was missing process_registry.kill_all(),
   so background processes from agents evicted without close() (branch,
   fallback) survived shutdown.
2026-04-10 16:51:44 -07:00
pefontana
f00dd3169f fix(gateway): guard _agent_cache_lock access in reset handler
Use getattr guard for _agent_cache_lock in _handle_reset_command
because test fixtures may create GatewayRunner without calling
__init__, leaving the attribute unset.

Fixes e2e test failure: test_new_resets_session,
test_new_then_status_reflects_reset, test_new_is_idempotent.
2026-04-10 16:51:44 -07:00
pefontana
8414f41856 test: add zombie process cleanup tests
Add 9 tests covering the full zombie process prevention chain:

- TestZombieReproduction: demonstrates that processes survive when
  references are dropped without explicit cleanup (the original bug)
- TestAgentCloseMethod: verifies close() calls all cleanup functions,
  is idempotent, propagates to children, and continues cleanup even
  when individual steps fail
- TestGatewayCleanupWiring: verifies stop() calls close() and that
  _evict_cached_agent() does NOT call close() (since it's also used
  for non-destructive cache refreshes)
- TestDelegationCleanup: calls the real _run_single_child function and
  verifies close() is called on the child agent

Ref: #7131
2026-04-10 16:51:44 -07:00
pefontana
672cc80915 fix(delegate): close child agent after delegation completes
Call child.close() in the _run_single_child finally block after
unregistering the child from the parent's active children list.

Previously child AIAgent instances were only removed from the tracking
list but never had their resources released — the OpenAI/httpx client
and any tool subprocesses relied entirely on garbage collection.

Ref: #7131
2026-04-10 16:51:44 -07:00
pefontana
fbe28352e4 fix(gateway): call agent.close() on session end to prevent zombies
Wire AIAgent.close() into every gateway code path where an agent's
session is actually ending:

- stop(): close all running agents after interrupt + memory shutdown,
  then call cleanup_all_environments() and cleanup_all_browsers() as
  a global catch-all
- _session_expiry_watcher(): close agents when sessions expire after
  the 5-minute idle timeout
- _handle_reset_command(): close the old agent before evicting it from
  cache on /new or /reset

Note: _evict_cached_agent() intentionally does NOT call close() because
it is also used for non-destructive cache refreshes (model switch,
branch, fallback) where tool resources should persist.

Ref: #7131
2026-04-10 16:51:44 -07:00
pefontana
5b42aecfa7 feat(agent): add AIAgent.close() for subprocess cleanup
Add a close() method to AIAgent that acts as a single entry point for
releasing all resources held by an agent instance. This prevents zombie
process accumulation on long-running gateway deployments by explicitly
cleaning up:

- Background processes tracked in ProcessRegistry
- Terminal sandbox environments
- Browser daemon sessions
- Active child agents (subagent delegation)
- OpenAI/httpx client connections

Each cleanup step is independently guarded so a failure in one does not
prevent the rest. The method is idempotent and safe to call multiple
times.

Also simplifies the background review cleanup to use close() instead
of manually closing the OpenAI client.

Ref: #7131
2026-04-10 16:51:44 -07:00
entropidelic
989b950fbc fix(security): enforce API_SERVER_KEY for non-loopback binding
Add is_network_accessible() helper using Python's ipaddress module to
robustly classify bind addresses (IPv4/IPv6 loopback, wildcards,
mapped addresses, hostname resolution with DNS-failure-fails-closed).

The API server connect() now refuses to start when the bind address is
network-accessible and no API_SERVER_KEY is set, preventing RCE from
other machines on the network.

Co-authored-by: entropidelic <entropidelic@users.noreply.github.com>
2026-04-10 16:51:44 -07:00
Devorun
2a6cbf52d0 fix(cron): prevent silent data loss by raising exceptions on unrecoverable jobs.json read failures (#6797) 2026-04-10 16:51:35 -07:00
coffee
c5ab760528 fix(cron): missing field init, unnecessary save, and shutdown cleanup
1. Add missing `last_delivery_error` field initialization in `create_job()`.
   `mark_job_run()` sets this field on line 596 but it was never initialized,
   causing inconsistent job schemas between new and executed jobs.

2. Replace unnecessary `save_jobs()` call with a warning log when
   `mark_job_run()` is called with a non-existent job_id. Previously the
   function would silently write unchanged data to disk.

3. Add `cancel_futures=True` to the `finally` block in cron scheduler's
   thread pool shutdown. The `except` path already passes this flag but
   the normal exit path did not, leaving futures running after inactivity
   timeout detection.
2026-04-10 16:51:35 -07:00
Teknium
a4fc38c5b1 test: remove dead TestResolveForcedProvider tests (function doesn't exist on main) 2026-04-10 16:47:44 -07:00
KUSH42
0e939af7c2 fix(patch): harden V4A patch parser and fuzzy match — 9 correctness bugs
- Bug 1: replace read_file(limit=10000) with read_file_raw in _apply_update,
  preventing silent truncation of files >2000 lines and corruption of lines
  >2000 chars; add read_file_raw to FileOperations abstract interface and
  ShellFileOperations

- Bug 2: split apply_v4a_operations into validate-then-apply phases; if any
  hunk fails validation, zero writes occur (was: continue after failure,
  leaving filesystem partially modified)

- Bug 3: parse_v4a_patch now returns an error for begin-marker-with-no-ops,
  empty file paths, and moves missing a destination (was: always returned
  error=None)

- Bug 4: raise strategy 7 (block anchor) single-candidate similarity threshold
  from 0.10 to 0.50, eliminating false-positive matches in repetitive code

- Bug 5: add _strategy_unicode_normalized (new strategy 7) with position
  mapping via _build_orig_to_norm_map; smart quotes and em-dashes in
  LLM-generated patches now match via strategies 1-6 before falling through
  to fuzzy strategies

- Bug 6: extend fuzzy_find_and_replace to return 4-tuple (content, count,
  error, strategy); update all 5 call sites across patch_parser.py,
  file_operations.py, and skill_manager_tool.py

- Bug 7: guard in _apply_update returns error when addition-only context hint
  is ambiguous (>1 occurrences); validation phase errors on both 0 and >1

- Bug 8: _apply_delete returns error (not silent success) on missing file

- Bug 9: _validate_operations checks source existence and destination absence
  for MOVE operations before any write occurs
2026-04-10 16:47:44 -07:00
Billard
475cbce775 fix(aux): honor api_mode for custom auxiliary endpoints 2026-04-10 16:47:44 -07:00
coffee
c1f832a610 fix(tools): guard against ValueError on int() env var and header parsing
Three locations perform `int()` conversion on environment variables or
HTTP headers without error handling, causing unhandled `ValueError` crashes
when the values are non-numeric:

1. `send_message_tool.py` — `EMAIL_SMTP_PORT` env var parsed outside the
   try/except block; a non-numeric value crashes `_send_email()` instead
   of returning a user-friendly error.

2. `process_registry.py` — `TERMINAL_TIMEOUT` env var parsed without
   protection; a non-numeric value crashes the `wait()` method.

3. `skills_hub.py` — HTTP `Retry-After` header can contain date strings
   per RFC 7231; `int()` conversion crashes on non-numeric values.

All three now fall back to their default values on `ValueError`/`TypeError`.
2026-04-10 16:47:44 -07:00
Awsh1
6f63ba9c8f fix(mcp): fall back when SIGKILL is unavailable 2026-04-10 16:47:44 -07:00
Fran Fitzpatrick
3e24ba1656 feat(matrix): add MATRIX_DM_MENTION_THREADS env var
When enabled, @mentioning the bot in a DM creates a thread (default:
false). Supports both env var and YAML config (matrix.dm_mention_threads).
6 new tests, docs updated.

From #6957
2026-04-10 15:46:20 -07:00
buray
d8cd7974d8 fix(feishu): register group chat member event handlers
Bot-added and bot-removed events were silently dropped because
_on_bot_added_to_chat and _on_bot_removed_from_chat were not
registered in _build_event_handler().

From #6975
2026-04-10 15:46:20 -07:00
Teknium
e8f16f7432 fix(docker): add missing skins/plans/workspace dirs to entrypoint
The profile system expects these directories but they weren't
being created on container startup. Adds them to the mkdir list
alongside the existing dirs.

Co-authored-by: Tranquil-Flow <tranquil_flow@protonmail.com>
2026-04-10 15:42:30 -07:00
duerzy
e1167c5c07 fix(deps): add socks extra to httpx for SOCKS proxy support
Add the [socks] extra to the httpx dependency to include the required
'socksio' package. This fixes the error: "Using SOCKS proxy, but the
'socksio' package is not installed" when users configure SOCKS proxy
settings.
2026-04-10 15:42:30 -07:00
angelos
8254b820ec fix(docker): --init for zombie reaping + sleep infinity for idle-based lifetime
Two issues with sandbox container spawning:

1. PID 1 was `sleep 2h` which doesn't call wait() — every background
   process that exited became a zombie (<defunct>), and the process
   tool reported them as "running" because zombie PIDs still exist in
   the process table. Fix: add --init to docker run, which uses
   tini (Docker) or catatonit (Podman) as PID 1 to reap children
   automatically. Both runtimes support --init natively.

2. The fixed 2-hour lifetime was arbitrary and sometimes too short
   for long agent sessions. Fix: replace 'sleep 2h' with
   'sleep infinity'. The idle reaper (_cleanup_inactive_envs, gated
   by terminal.lifetime_seconds, default 300s) already handles
   cleanup based on last activity timestamp — there's no need for
   the container itself to have a fixed death timer.

Fixes #6908.
2026-04-10 15:42:30 -07:00
Tranquil-Flow
2b0912ab18 fix(install): handle Playwright deps correctly on non-apt systems
Playwright's --with-deps flag only supports apt-based dependency
installation. The install script previously ran it on all non-Arch
systems, failing silently on Gentoo, Fedora, openSUSE, and others.

- Restrict --with-deps to known apt-based distributions
- Add explicit guidance for RPM-based (dnf) and zypper-based systems
- Show visible warnings instead of suppressing failures with || true
- Correct misleading comment that claimed dnf/zypper support

Fixes #6865
2026-04-10 15:42:30 -07:00
Teknium
ea81aa2eec fix: guard api_kwargs in except handler to prevent UnboundLocalError (#7376)
When _build_api_kwargs() throws an exception, the except handler in
the retry loop referenced api_kwargs before it was assigned. This
caused an UnboundLocalError that masked the real error, making
debugging impossible for the user.

Two _dump_api_request_debug() calls in the except block (non-retryable
client error path and max-retries-exhausted path) both accessed
api_kwargs without checking if it was assigned.

Fix: initialize api_kwargs = None before the retry loop and guard both
dump calls. Now the real error surfaces instead of the masking
UnboundLocalError.

Reported by Discord user gruman0.
2026-04-10 15:12:00 -07:00
Teknium
496e378b10 fix: resolve overlay provider slug mismatch in /model picker (#7373)
HERMES_OVERLAYS keys use models.dev IDs (e.g. 'github-copilot') but
_PROVIDER_MODELS curated lists and config.yaml use Hermes provider IDs
('copilot'). list_authenticated_providers() Section 2 was using the
overlay key directly for model lookups and is_current checks, causing:
- 0 models shown for copilot, kimi, kilo, opencode, vercel
- is_current never matching the config provider

Fix: build reverse mapping from PROVIDER_TO_MODELS_DEV to translate
overlay keys to Hermes slugs before curated list lookup and result
construction. Also adds 'kimi-for-coding' alias in auth.py so the
picker's returned slug resolves correctly in resolve_provider().

Fixes #5223. Based on work by HearthCore (#6492) and linxule (#6287).

Co-authored-by: HearthCore <HearthCore@users.noreply.github.com>
Co-authored-by: linxule <linxule@users.noreply.github.com>
2026-04-10 14:46:57 -07:00
Shannon Sands
03f23f10e1 feat: multi-agent Discord filtering — skip messages addressed to other bots
Replace the simple DISCORD_IGNORE_NO_MENTION check with bot-aware
multi-agent filtering. When multiple agents share a channel:

- If other bots are @mentioned but this bot is not → stay silent
- If only humans are mentioned but not this bot → stay silent
- Messages with no mentions still flow to _handle_message for the
  existing DISCORD_REQUIRE_MENTION check
- DMs are unaffected (always handled)

This prevents both agents from responding when only one is addressed.
2026-04-11 07:46:44 +10:00
Julien Talbot
8bcb8b8e87 feat(providers): add native xAI provider
Adds xAI as a first-class provider: ProviderConfig in auth.py,
HermesOverlay in providers.py, 11 curated Grok models, URL mapping
in model_metadata.py, aliases (x-ai, x.ai), and env var tests.
Uses standard OpenAI-compatible chat completions.

Closes #7050
2026-04-10 13:40:38 -07:00
0xbyt4
f07b35acba fix: use raw docstring to suppress invalid escape sequence warning 2026-04-10 13:39:30 -07:00
Teknium
363d5d57be test: update schema assertion after maxItems removal 2026-04-10 13:38:14 -07:00
angelos
7ccdb74364 fix(delegate): make max_concurrent_children configurable + error on excess
`delegate_task` silently truncated batch tasks to 3 — the model sends
5 tasks, gets results for 3, never told 2 were dropped. Now returns a
clear tool_error explaining the limit and how to fix it.

The limit is configurable via:
  - delegation.max_concurrent_children in config.yaml (priority 1)
  - DELEGATION_MAX_CONCURRENT_CHILDREN env var (priority 2)
  - default: 3

Uses the same _load_config() path as the rest of delegate_task for
consistent config priority. Clamps to min 1, warns on non-integer
config values.

Also removes the hardcoded maxItems: 3 from the JSON schema — the
schema was blocking the model from even attempting >3 tasks before
the runtime check could fire. The runtime check gives a much more
actionable error message.

Backwards compatible: default remains 3, existing configs unchanged.
2026-04-10 13:38:14 -07:00
Tranquil-Flow
6c115440fd fix(delegate): sync self.base_url with client_kwargs after credential resolution
When delegation.base_url routes subagents to a different endpoint, the
correct URL was passed through _resolve_delegation_credentials() and
_build_child_agent() into AIAgent.__init__(), but self.base_url could
fall out of sync with client_kwargs["base_url"] — the value the OpenAI
client actually uses.

This caused billing_base_url in session records to show the parent's
endpoint while actual API calls went to the correct delegation target.

Keep self.base_url in sync with client_kwargs after the credential
resolution block, matching the existing pattern for self.api_key.

Fixes #6825
2026-04-10 13:38:14 -07:00
Teknium
4fb42d0193 fix: per-profile subprocess HOME isolation (#4426) (#7357)
Isolate system tool configs (git, ssh, gh, npm) per profile by injecting
a per-profile HOME into subprocess environments only.  The Python
process's own os.environ['HOME'] and Path.home() are never modified,
preserving all existing profile infrastructure.

Activation is directory-based: when {HERMES_HOME}/home/ exists on disk,
subprocesses see it as HOME.  The directory is created automatically for:
- Docker: entrypoint.sh bootstraps it inside the persistent volume
- Named profiles: added to _PROFILE_DIRS in profiles.py

Injection points (all three subprocess env builders):
- tools/environments/local.py _make_run_env() — foreground terminal
- tools/environments/local.py _sanitize_subprocess_env() — background procs
- tools/code_execution_tool.py child_env — execute_code sandbox

Single source of truth: hermes_constants.get_subprocess_home()

Closes #4426
2026-04-10 13:37:45 -07:00
Teknium
f83e86d826 feat(cli): restore live per-tool elapsed timer in TUI spinner (#7359)
Brings back the live elapsed time counter that was lost when the CLI
transitioned from raw KawaiiSpinner animation to prompt_toolkit TUI.

The original implementation (Feb 2026) used KawaiiSpinner per tool call
with \r-based animation showing '(4.2s)' ticking up live. When
patch_stdout was introduced, the \r animation was disabled and replaced
with a static _spinner_text widget that only showed the tool name.

Now the spinner widget shows elapsed time again:
  💻 git log --oneline  (3.2s)

Implementation:
- Track _tool_start_time (monotonic) on tool.started events
- Clear it on tool.completed and thinking transitions
- get_spinner_text() computes live elapsed on each TUI repaint
- The existing poll loop already invalidates every ~0.15s, so no
  extra timer thread is needed

Addresses #4287.
2026-04-10 13:09:41 -07:00
0xbyt4
0bea603510 fix: handle NoneType request_overrides in fast_mode check (#7350) 2026-04-10 13:07:25 -07:00
Teknium
360b21ce95 fix(gateway): reject file paths in get_command() + file-drop tests (#7356)
Gateway get_command() now rejects paths containing /. Also adds 28 _detect_file_drop regression tests. From #6978 (@ygd58) and #6963 (@betamod).
2026-04-10 13:06:02 -07:00
kshitijk4poor
37a1c75716 fix(browser): hardening — dead code, caching, scroll perf, security, thread safety
Salvaged from PR #7276 (hardening-only subset; excluded 6 new tools
and unrelated scope additions from the contributor's commit).

- Remove dead DEFAULT_SESSION_TIMEOUT and unregistered browser_close schema
- Fix _camofox_eval wrong call signatures (_ensure_tab, _post args)
- Cache _find_agent_browser, _get_command_timeout, _discover_homebrew_node_dirs
- Replace 5x subprocess scroll loop with single pixel-arg call
- URL-decode before secret exfiltration check (bypass prevention)
- Protect _recording_sessions with _cleanup_lock (thread safety)
- Return failure on empty stdout instead of silent success
- Structure-aware _truncate_snapshot (cut at line boundaries)

Follow-up improvements over contributor's original:
- Move _EMPTY_OK_COMMANDS to module-level frozenset (avoid per-call allocation)
- Fix list+tuple concat in _run_browser_command PATH construction
- Update test_browser_homebrew_paths.py for tuple returns and cache fixtures

Co-authored-by: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com>
Closes #7168, closes #7171, closes #7172, closes #7173
2026-04-10 13:05:44 -07:00
WAXLYY
c6e1add6f1 fix(agent): preserve quoted @file references with spaces 2026-04-10 13:05:01 -07:00
Hermes Audit
2c99b4e79b fix(unicode): sanitize surrogate metadata and allow two-pass retry 2026-04-10 13:05:01 -07:00
Hermes Audit
71036a7a75 fix: handle UnicodeEncodeError with ASCII codec (#6843)
Broaden the UnicodeEncodeError recovery to handle systems with ASCII-only
locale (LANG=C, Chromebooks) where ANY non-ASCII character causes encoding
failure, not just lone surrogates.

Changes:
- Add _strip_non_ascii() and _sanitize_messages_non_ascii() helpers that
  strip all non-ASCII characters from message content, name, and tool_calls
- Update the UnicodeEncodeError handler to detect ASCII codec errors and
  fall back to non-ASCII sanitization after surrogate check fails
- Sanitize tool_calls arguments and name fields (not just content)
- Fix bare .encode() in cli.py suspend handler to use explicit utf-8
- Add comprehensive test suite (17 tests)
2026-04-10 13:05:01 -07:00
Teknium
7e28b7b5d5 fix: parallelize skills browse/search to prevent hanging (#7301)
hermes skills browse ran all 7 source adapters serially with no overall
timeout and no progress indicator. On a cold cache, GitHubSource alone
could make 100+ sequential HTTP calls (directory listing + inspect per
skill per tap), taking 5+ minutes with no output — appearing to hang.

Changes:
- Add parallel_search_sources() in tools/skills_hub.py that runs all
  source adapters concurrently via ThreadPoolExecutor with a 30s
  overall timeout. Sources that finish in time contribute results;
  slow ones are skipped gracefully with a visible notice.
- Update unified_search() to use parallel_search_sources() internally.
- Update do_browse() and do_search() in hermes_cli/skills_hub.py to
  show a Rich spinner while fetching, so the user sees activity.
- Bump per-source limits (clawhub 50→500, lobehub 50→500, etc.) now
  that fetching is parallel — yields far more results per browse.
- Report timed-out sources and suggest re-running for cached results.
- Replace 'inspect/install' footer with 'search deeper' tip.

Worst-case latency drops from 5+ minutes (serial) to ~30s (parallel
with timeout cap). Result count should jump from ~242 to 1000+.
2026-04-10 12:54:18 -07:00
Teknium
a093eb47f7 fix: propagate child activity to parent during delegate_task (#7295)
When delegate_task runs, the parent agent's activity tracker freezes
because child.run_conversation() blocks and the child's own
_touch_activity() never propagates back to the parent. The gateway
inactivity timeout then fires a spurious 'No activity' warning and
eventually kills the agent, even though the subagent is actively working.

Fix: add a heartbeat thread in _run_single_child that calls
parent._touch_activity() every 30 seconds with detail from the child's
activity summary (current tool, iteration count). The thread is a daemon
that starts before child.run_conversation() and is cleaned up in the
finally block.

This also improves the gateway 'Still working...' status messages —
instead of just 'running: delegate_task', users now see what the
subagent is actually doing (e.g., 'delegate_task: subagent running
terminal (iteration 5/50)').
2026-04-10 12:51:30 -07:00
Teknium
f72faf191c fix: fall back to default certs when CA bundle path doesn't exist (#7352)
_resolve_verify() returned stale CA bundle paths from auth.json without
checking if the file exists. When a user logs into Nous Portal on their
host (where SSL_CERT_FILE points to a valid cert), that path gets
persisted in auth.json. Running hermes model later in Docker where the
host path doesn't exist caused FileNotFoundError bubbling up as
'Could not verify credentials: [Errno 2] No such file or directory'.

Now _resolve_verify validates the path exists before returning it. If
missing, logs a warning and falls back to True (default certifi-based
TLS verification).
2026-04-10 12:51:19 -07:00
Teknium
7e60b09274 fix: add _session_model_overrides to test runner fixture
Follow-up for cherry-pick — _session_model_overrides was added to
GatewayRunner.__init__ after the fast mode PR was written.
2026-04-10 05:54:56 -07:00
Felix Cardix
970192f183 feat(gateway): add fast mode support to gateway chats 2026-04-10 05:54:56 -07:00
Kenny Xie
5b8beb0ead fix(gateway): handle provider command without config 2026-04-10 05:54:56 -07:00
Teknium
7cec784b64 fix: complete Weixin platform parity audit — 16 missing integration points
Systematic audit found Weixin missing from:

Code:
- gateway/run.py: early WEIXIN_ALLOW_ALL_USERS env check
- gateway/platforms/webhook.py: cross-platform delivery routing
- hermes_cli/dump.py: platform detection for config export
- hermes_cli/setup.py: hermes setup wizard platform list + _setup_weixin
- hermes_cli/skills_config.py: platform labels for skills config UI

Docs (11 pages):
- developer-guide/architecture.md: platform adapter listing
- developer-guide/cron-internals.md: delivery target table
- developer-guide/gateway-internals.md: file tree
- guides/cron-troubleshooting.md: supported platforms list
- integrations/index.md: platform links
- reference/toolsets-reference.md: toolset table
- user-guide/configuration.md: platform keys for tool_progress
- user-guide/features/cron.md: delivery target table
- user-guide/messaging/index.md: intro text, feature table,
  mermaid diagram, toolset table, setup links
- user-guide/messaging/webhooks.md: deliver field + routing table
- user-guide/sessions.md: platform identifiers table
2026-04-10 05:54:37 -07:00
Teknium
be4f049f46 fix: salvage follow-ups for Weixin adapter (#6747)
- Remove sys.path.insert hack (leftover from standalone dev)
- Add token lock (acquire_scoped_lock/release_scoped_lock) in
  connect()/disconnect() to prevent duplicate pollers across profiles
- Fix get_connected_platforms: WEIXIN check must precede generic
  token/api_key check (requires both token AND account_id)
- Add WEIXIN_HOME_CHANNEL_NAME to _EXTRA_ENV_KEYS
- Add gateway setup wizard with QR login flow
- Add platform status check for partially configured state
- Add weixin.md docs page with full adapter documentation
- Update environment-variables.md reference with all 11 env vars
- Update sidebars.ts to include weixin docs page
- Wire all gateway integration points onto current main

Salvaged from PR #6747 by Zihan Huang.
2026-04-10 05:54:37 -07:00
Zihan Huang
5b63bf7f9a feat(gateway): add native Weixin/WeChat support via iLink Bot API
Add first-class Weixin platform adapter for personal WeChat accounts:
- Long-poll inbound delivery via iLink getupdates
- AES-128-ECB encrypted CDN media upload/download
- QR-code login flow for gateway setup wizard
- context_token persistence for reply continuity
- DM/group access policies with allowlists
- Native text, image, video, file, voice handling
- Markdown formatting with header rewriting and table-to-list conversion
- Block-aware message chunking (preserves fenced code blocks)
- Typing indicators via getconfig/sendtyping
- SSRF protection on remote media downloads
- Message deduplication with TTL

Integration across all gateway touchpoints:
- Platform enum, config, env overrides, connected platforms check
- Adapter creation in gateway runner
- Authorization maps (allowed users, allow all)
- Cron delivery routing
- send_message tool with native media support
- Toolset definition (hermes-weixin)
- Channel directory (session-based)
- Platform hint in prompt builder
- CLI status display
- hermes tools default toolset mapping

Co-authored-by: Zihan Huang <bravohenry@users.noreply.github.com>
2026-04-10 05:54:37 -07:00
Teknium
4a65c9cd08 fix: profile paths broken in Docker — profiles go to /root/.hermes instead of mounted volume (#7170)
In Docker, HERMES_HOME=/opt/data (set in Dockerfile) and users mount
their .hermes directory to /opt/data. However, profile operations used
Path.home() / '.hermes' which resolves to /root/.hermes in Docker —
an ephemeral container path, not the mounted volume.

This caused:
- Profiles created at /root/.hermes/profiles/ (lost on container recreate)
- active_profile sticky file written to wrong location
- profile list looking at wrong directory

Fix: Add get_default_hermes_root() to hermes_constants.py that detects
Docker/custom deployments (HERMES_HOME outside ~/.hermes) and returns
HERMES_HOME as the root. Also handles Docker profiles correctly
(<root>/profiles/<name> → root is grandparent).

Files changed:
- hermes_constants.py: new get_default_hermes_root()
- hermes_cli/profiles.py: _get_default_hermes_home() delegates to shared fn
- hermes_cli/main.py: _apply_profile_override() + _invalidate_update_cache()
- hermes_cli/gateway.py: _profile_suffix() + _profile_arg()
- Tests: 12 new tests covering Docker scenarios
2026-04-10 05:53:10 -07:00
Kenny Xie
916fbf362c fix(model): tighten direct-provider fallback normalization 2026-04-10 05:52:45 -07:00
Kenny Xie
b730c2955a fix(model): normalize direct provider ids in auxiliary routing 2026-04-10 05:52:45 -07:00
Kenny Xie
fd5cc6e1b4 fix(model): normalize native provider-prefixed model ids 2026-04-10 05:52:45 -07:00
r266-tech
1662b7f82a fix(test): correct mock target for fetch_api_models in custom provider tests
fetch_api_models is imported locally inside _model_flow_named_custom from
hermes_cli.models, not defined as a module-level attribute of hermes_cli.main.
Patch the source module so the local import picks up the mock.

Also force simple_term_menu ImportError so tests reliably use the input()
fallback path regardless of environment.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-10 05:52:45 -07:00
r266-tech
e3b395e17d test: add regression tests for custom provider model switching
Covers: probe always called, model switch works, probe failure fallback,
first-time flow unchanged.
2026-04-10 05:52:45 -07:00
r266-tech
0cdf5232ae fix: always show model selection menu for custom providers
Previously, _model_flow_named_custom() returned immediately when a saved
model existed, making it impossible to switch models on multi-model
endpoints (OpenRouter, vLLM clusters, etc.).

Now the function always probes the endpoint and shows the selection menu
with the current model pre-selected and marked '(current)'. Falls back
to the saved model if endpoint probing fails.

Fixes #6862
2026-04-10 05:52:45 -07:00
Ronald Reis
49bba1096e fix: opencode-go missing from /model list and improve HERMES_OVERLAYS credential check
When opencode-go API key is set, it should appear in the /model list.
The provider was already in PROVIDER_TO_MODELS_DEV and PROVIDER_REGISTRY,
so it appears via Part 1 (built-in source).

Also fixes a potential issue in Part 2 (HERMES_OVERLAYS) where providers
with auth_type=api_key but no extra_env_vars would not be detected:
- Now also checks api_key_env_vars from PROVIDER_REGISTRY for api_key auth_type

- Add test verifying opencode-go appears when OPENCODE_GO_API_KEY is set
2026-04-10 05:52:45 -07:00
Ronald Reis
fd3e855d58 fix: pass config_context_length to switch_model context compressor
When switching models at runtime, the config_context_length override
was not being passed to the new context compressor instance. This
meant the user-specified context length from config.yaml was lost
after a model switch.

- Store _config_context_length on AIAgent instance during __init__
- Pass _config_context_length when creating new ContextCompressor in switch_model
- Add test to verify config_context_length is preserved across model switches

Fixes: quando estamos alterando o modelo não está alterando o tamanho do contexto
2026-04-10 05:52:45 -07:00
Teknium
5fc5ced972 fix: add Alibaba/DashScope rate-limit pattern to error classifier
Port from anomalyco/opencode#21355: Alibaba's DashScope API returns a
unique throttling message ('Request rate increased too quickly...') that
doesn't match standard rate-limit patterns ('rate limit', 'too many
requests'). This caused Alibaba errors to fall through to the 'unknown'
category rather than being properly classified as rate_limit with
appropriate backoff/rotation.

Add 'rate increased too quickly' to _RATE_LIMIT_PATTERNS and test with
the exact error message observed from the Alibaba provider.
2026-04-10 05:52:45 -07:00
Teknium
0e315a6f02 fix(telegram): use valid reaction emojis for processing completion (#7175)
Telegram's Bot API only allows a specific set of emoji for bot reactions
(the ReactionEmoji enum).  (U+2705) and  (U+274C) are not in that
set, causing on_processing_complete reactions to silently fail with
REACTION_INVALID (caught at debug log level).

Replace with 👍 (U+1F44D) / 👎 (U+1F44E) which are always available in
Telegram's allowed reaction list. The 👀 (eyes) reaction used by
on_processing_start was already valid.

Based on the fix by @ppdng in PR #6685.

Fixes #6068
2026-04-10 05:34:33 -07:00
Teknium
6d2fa03837 fix: UTF-8 config encoding, pairing hint, credential_pool key, header normalization (#7174)
Four small fixes: (1) UTF-8 encoding for config open (@zhangchn #7063), (2) pairing hint placeholders (@konsisumer #7057), (3) missing credential_pool in cheap route (@kuishou68 #7025), (4) case-insensitive rate limit headers (@kuishou68 #7019).
2026-04-10 05:33:48 -07:00
Teknium
f3ae1d765d fix: flush stdin after curses/terminal menus to prevent escape sequence leakage (#7167)
After curses.wrapper() or simple_term_menu exits, endwin() restores the
terminal but does NOT drain the OS input buffer. Leftover escape-sequence
bytes from arrow key navigation remain buffered and get silently consumed
by the next input()/getpass.getpass() call.

This caused a user-reported bug where selecting Z.AI/GLM as provider wrote
^[^[ (two ESC chars) into .env as the API key, because the buffered escape
bytes were consumed by getpass before the user could type anything.

Fix: add flush_stdin() helper using termios.tcflush(TCIFLUSH) and call it
after every curses.wrapper() and simple_term_menu .show() return across all
interactive menu sites:
- hermes_cli/curses_ui.py (curses_checklist)
- hermes_cli/setup.py (_curses_prompt_choice)
- hermes_cli/tools_config.py (_prompt_choice)
- hermes_cli/auth.py (_prompt_model_selection)
- hermes_cli/main.py (3 simple_term_menu usages)
2026-04-10 05:32:31 -07:00
Teknium
49da1ff1b1 test(discord): add tests for channel_skill_bindings resolution 2026-04-10 05:19:26 -07:00
Teknium
76a1e6e0fe feat(discord): add channel_skill_bindings for auto-loading skills per channel
Simplified implementation of the feature from PR #6842 (RunzhouLi).
Allows Discord channels/forum threads to auto-bind skills via config:

    discord:
      channel_skill_bindings:
        - id: "123456"
          skills: ["skill-a", "skill-b"]

The run.py auto-skill loader now handles both str and list[str],
loading multiple skills in order and concatenating their payloads.
Forum threads inherit their parent channel's bindings.

Co-authored-by: RunzhouLi <RunzhouLi@users.noreply.github.com>
2026-04-10 05:19:26 -07:00
Fran Fitzpatrick
21bb2547c6 fix(matrix): log redact failures and add missing reaction test cases
Add debug logging when eyes reaction redaction fails, and add tests
for the success=False path and the no-pending-reaction edge case.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 05:19:26 -07:00
Fran Fitzpatrick
58413c411f test: update Matrix reaction tests for new _send_reaction return type
_send_reaction now returns Optional[str] (event_id) instead of bool.
Tests updated:
- test_send_reaction: assert result == event_id string
- test_send_reaction_no_client: assert result is None
- test_on_processing_start_sends_eyes: _send_reaction returns event_id,
  now also asserts _pending_reactions is populated
- test_on_processing_complete_sends_check: set up _pending_reactions and
  mock _redact_reaction, assert eyes reaction is redacted before sending check
2026-04-10 05:19:26 -07:00
Fran Fitzpatrick
cc12ab8290 fix(matrix): remove eyes reaction on processing complete
The on_processing_complete handler was never removing the eyes reaction because
_send_reaction didn't return the reaction event_id.

Fix:
- _send_reaction returns Optional[str] event_id
- on_processing_start stores it in _pending_reactions dict
- on_processing_complete redacts the eyes reaction before adding completion emoji
2026-04-10 05:19:26 -07:00
Zainan Victor Zhou
74e883ca37 fix(cli): make /status show gateway-style session status 2026-04-10 05:19:26 -07:00
spniyant
e376a9b2c9 feat(telegram): support custom base_url for credential proxy
When extra.base_url is set in the Telegram platform config, use it as
the base URL for all Telegram API requests instead of api.telegram.org.
This allows agents to route Telegram traffic through the credential
proxy, which injects the real bot token — the VM never sees it.

Also supports extra.base_file_url for file downloads (defaults to
base_url if not set separately).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 05:19:26 -07:00
佐藤栄
2629927032 fix(feishu): wrap image bytes in BytesIO before uploading to lark SDK 2026-04-10 05:19:26 -07:00
win4r
aedf6c7964 security(approval): close 4 pattern gaps found by source-grounded audit
Four gaps in DANGEROUS_PATTERNS found by running 10 targeted tests that
each mapped to a specific pattern in approval.py and checked whether the
documented defense actually held.

1. **Heredoc script injection** — `python3 << 'EOF'` bypasses the
   existing `-e`/`-c` flag pattern. Adds pattern for interpreter + `<<`
   covering python{2,3}, perl, ruby, node.

2. **PID expansion self-termination** — `kill -9 $(pgrep hermes)` is
   opaque to the existing `pkill|killall` + name pattern because command
   substitution is not expanded at detection time. Adds structural
   patterns matching `kill` + `$(pgrep` and backtick variants.

3. **Git destructive operations** — `git reset --hard`, `push --force`,
   `push -f`, `clean -f*`, and `branch -D` were entirely absent.
   Note: `branch -d` also triggers because IGNORECASE is global —
   acceptable since -d is still a delete, just a safe one, and the
   prompt is only a confirmation, not a hard block.

4. **chmod +x then execute** — two-step social engineering where a
   script containing dangerous commands is first written to disk (not
   checked by write_file), then made executable and run as `./script`.
   Pattern catches `chmod +x ... [;&|]+ ./` combos. Does not solve the
   deeper architectural issue (write_file not checking content) — that
   is called out in the PR description as a known limitation.

Tests: 23 new cases across 4 test classes, all in test_approval.py:
  - TestHeredocScriptExecution (7 cases, incl. regressions for -c)
  - TestPgrepKillExpansion (5 cases, incl. safe kill PID negative)
  - TestGitDestructiveOps (8 cases, incl. safe git status/push negatives)
  - TestChmodExecuteCombo (3 cases, incl. safe chmod-only negative)

Full suite: 146 passed, 0 failed.
2026-04-10 05:19:21 -07:00
xwp
5a1cce53e4 fix(auxiliary): skip anthropic in fallback chain when not explicitly configured
_resolve_api_key_provider() now checks is_provider_explicitly_configured
before calling _try_anthropic().  Previously, any auxiliary fallback
(e.g. when kimi-coding key was invalid) would silently discover and use
Claude Code OAuth tokens — consuming the user's Claude Max subscription
without their knowledge.

This is the auxiliary-client counterpart of the setup-wizard gate in
PR #4210.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 05:19:21 -07:00
xwp
419b719c2b fix(auth): make 'auth remove' for claude_code prevent re-seeding
Previously, removing a claude_code credential from the anthropic pool
only printed a note — the next load_pool() re-seeded it from
~/.claude/.credentials.json.  Now writes a 'suppressed_sources' flag
to auth.json that _seed_from_singletons checks before seeding.

Follows the pattern of env: source removal (clears .env var) and
device_code removal (clears auth store state).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 05:19:21 -07:00
xwp
f3fb3eded4 fix(auth): gate Claude Code credential seeding behind explicit provider config
_seed_from_singletons('anthropic') now checks
is_provider_explicitly_configured('anthropic') before reading
~/.claude/.credentials.json.  Without this, the auxiliary client
fallback chain silently discovers and uses Claude Code tokens when
the user's primary provider key is invalid — consuming their Claude
Max subscription quota without consent.

Follows the same gating pattern as PR #4210 (setup wizard gate)
but applied to the credential pool seeding path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 05:19:21 -07:00
xwp
d7164603da feat(auth): add is_provider_explicitly_configured() helper
Gate function for checking whether a user has explicitly selected a
provider via hermes model/setup, auth.json active_provider, or env
vars.  Used in subsequent commits to prevent unauthorized credential
auto-discovery.  Follows the pattern from PR #4210.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 05:19:21 -07:00
Dusk1e
e683c9db90 fix(security): enforce path boundary checks in skill manager operations 2026-04-10 05:19:21 -07:00
Teknium
7663c98c1e fix: make safe_url_for_log public, add SSRF redirect guards to base.py cache helpers
Follow-up to Dusk1e's PR #7120 (Slack send_image redirect guard):
- Rename _safe_url_for_log -> safe_url_for_log (drop underscore) since
  it is now imported cross-module by the Slack adapter
- Add _ssrf_redirect_guard httpx event hook to cache_image_from_url()
  and cache_audio_from_url() in base.py — same pattern as vision_tools
  and the Slack adapter fix
- Update url_safety.py docstring to reflect broader coverage
- Add regression tests for image/audio redirect blocking + safe passthrough
2026-04-10 05:04:28 -07:00
Dusk1e
714809634f fix(security): prevent SSRF redirect bypass in Slack adapter 2026-04-10 05:04:28 -07:00
Teknium
f4c7086035 fix(api-server): share one Docker container across all API conversations (#7127)
The API server's _run_agent() was not passing task_id to
run_conversation(), causing a fresh random UUID per request. This meant
every Open WebUI message spun up a new Docker container and tore it down
afterward — making persistent filesystem state impossible.

Two fixes:

1. Pass task_id="default" so all API server conversations share the same
   Docker container (matching the design intent: one configured Docker
   environment, always the same container).

2. Derive a stable session_id from the system prompt + first user message
   hash instead of uuid4(). This stops hermes sessions list from being
   polluted with single-message throwaway sessions.

Fixes #3438.
2026-04-10 04:56:35 -07:00
Evi Nova
0b143f2ea3 fix(gateway): validate Slack image downloads before caching
Slack may return an HTML sign-in/redirect page instead of actual media
bytes (e.g. expired token, restricted file access). This adds two layers
of defense:

1. Content-Type check in slack.py rejects text/html responses early
2. Magic-byte validation in base.py's cache_image_from_bytes() rejects
   non-image data regardless of source platform

Also adds ValueError guards in wecom.py and email.py so the new
validation doesn't crash those adapters.

Closes #6829
2026-04-10 03:53:09 -07:00
Teknium
c8e4dcf412 fix: prevent duplicate completion notifications on process kill (#7124)
When kill_process() sends SIGTERM, both it and the reader thread race
to call _move_to_finished() — kill_process sets exit_code=-15 and
enqueues a notification, then the reader thread's process.wait()
returns with exit_code=143 (128+SIGTERM) and enqueues a second one.

Fix: make _move_to_finished() idempotent by tracking whether the
session was actually removed from _running. The second call sees it
was already moved and skips the completion_queue.put().

Adds regression test: test_move_to_finished_idempotent_no_duplicate
2026-04-10 03:52:16 -07:00
H-5-Isminiz
00dd5cc491 fix(gateway): implement platform-aware PID termination 2026-04-10 03:52:00 -07:00
KUSH42
9bb8cb8d83 fix(tests): repair three pre-existing gateway test failures
- test_background_autocompletes: pytest.importorskip("prompt_toolkit")
  so the test skips gracefully where the CLI dep is absent

- test_run_agent_progress_stays_in_originating_topic: update stale emoji
  💻⚙️ to match get_tool_emoji("terminal", default="⚙️") in run.py

- test_internal_event_bypass{_authorization,_pairing}: mock
  _handle_message_with_agent to raise immediately; avoids the 300s
  run_in_executor hang that caused the tests to time out
2026-04-10 03:52:00 -07:00
KUSH42
5dea7e1ebc fix(gateway): prevent duplicate messages on no-message-id platforms
Platforms that don't return a message_id after the first send (Signal,
GitHub webhooks) were causing GatewayStreamConsumer to re-enter the
"first send" path on every tool boundary, posting one platform message
per tool call (observed as 155 PR comments on a single response).

Fix: treat _message_id == "__no_edit__" as a sentinel meaning "platform
accepted the send but cannot be edited". When a tool boundary arrives
in that state, skip the message_id/accumulated/last_sent_text reset so
all continuation text is delivered once via _send_fallback_final rather
than re-posted per segment.

Also make prompt_toolkit imports in hermes_cli/commands.py optional so
gateway and test environments that lack the package can still import
resolve_command, gateway_help_lines, and COMMAND_REGISTRY.
2026-04-10 03:52:00 -07:00
zhouboli
b1e2b5ea74 fix(telegram): harden HTTPX request pools during reconnect
- configure Telegram HTTPXRequest pool/timeouts with env-overridable defaults\n- use separate request/get_updates request objects to reduce pool contention\n- skip fallback-IP transport when proxy is configured (or explicitly disabled)\n\nThis mitigates recurrent pool-timeout failures during polling reconnect/bootstrap (delete_webhook).
2026-04-10 03:52:00 -07:00
coffee
96f9b91489 fix(gateway): replace assertions with proper error handling in Telegram and Feishu
Python assertions are stripped when running with `python -O` (optimized
mode), making them unsuitable for runtime error handling.

1. `telegram_network.py:113` — After exhausting all fallback IPs, the code
   uses `assert last_error is not None` before `raise last_error`. In
   optimized mode, the assert is skipped; if `last_error` is unexpectedly
   None, `raise None` produces a confusing `TypeError` instead of a
   meaningful error. Replace with an explicit `if` check that raises
   `RuntimeError` with a descriptive message.

2. `feishu.py:975` — The `_configure_with_overrides` closure uses
   `assert original_configure is not None` as a guard. While the outer
   scope only installs this closure when `original_configure` is not None,
   the assert would silently disappear in optimized mode. Replace with an
   explicit `if` check for defensive safety.
2026-04-10 03:52:00 -07:00
Tranquil-Flow
bb3a4fc68e test(gateway): add /background to active-session bypass tests
Adds a regression test verifying that /background bypasses the
active-session guard in the platform adapter, matching the existing
test pattern for /stop, /new, /approve, /deny, and /status.
2026-04-10 03:52:00 -07:00
Tranquil-Flow
429da6cbce fix(gateway): route /background through active-session bypass
When /background was sent during an active run, it was not in the
platform adapter's bypass list and fell through to the interrupt path
instead of spawning a parallel background task.

Add "background" to the active-session command bypass in the platform
adapter, and add an early return in the gateway runner's running-agent
guard to route /background to _handle_background_command() before it
reaches the default interrupt logic.

Fixes #6827
2026-04-10 03:52:00 -07:00
Kenny Xie
4f2f09affa fix(gateway): avoid false failure reactions on restart cancellation 2026-04-10 03:52:00 -07:00
Teknium
af7d809354 fix: correct inaccuracies and add sidebar entry for cron troubleshooting guide
- Fix job state display: [active] not scheduled
- Fix CLI mode claim: only gateway fires cron, not CLI sessions
- Expand delivery targets table (5 → 10+ platforms with platform:chat_id syntax)
- Fix disabled toolsets: cronjob, messaging, and clarify (not just cronjob)
- Remove nonexistent 'hermes skills sync' command reference
- Fix log file path: agent.log/errors.log, not scheduler.log
- Fix execution model: sequential, not thread pool concurrent
- Fix 'hermes cron run' description: next tick, not immediate
- Add inactivity-based timeout details (HERMES_CRON_TIMEOUT)
- Add sidebar entry in sidebars.ts under Guides & Tutorials
2026-04-10 03:48:00 -07:00
Thomas Bale
fbfa7c27d5 docs: add cron troubleshooting guide
Adds a troubleshooting guide for Hermes cron jobs covering:
- Jobs not firing (schedule, gateway, timezone checks)
- Delivery failures (platform tokens, [SILENT], permissions)
- Skill loading failures (installed, ordering, interactive tools)
- Job errors (script paths, lock contention, permissions)
- Performance issues and diagnostic commands

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 03:48:00 -07:00
Yao
1bcc87a153 fix(acp): declare session load and resume capabilities in initialize response (#6985)
The resume_session and load_session handlers were implemented but undiscoverable by ACP clients because the capabilities weren't declared in the initialize response. Adds load_session=True and resume=SessionResumeCapabilities() plus wire-format tests. Fixes #6633. Contributed by @luyao618.
2026-04-10 03:45:36 -07:00
Teknium
437feabb74 fix(gateway): launchd_stop uses bootout so KeepAlive doesn't respawn (#7119)
launchd_stop() previously used `launchctl kill SIGTERM` which only
signals the process. Because the plist has KeepAlive.SuccessfulExit=false,
launchd immediately respawns the gateway — making `hermes gateway stop`
a no-op that prints '✓ Service stopped' while the service keeps running.

Switch to `launchctl bootout` which unloads the service definition so
KeepAlive can't trigger. The process exits and stays stopped until
`hermes gateway start` (which already handles re-bootstrapping unloaded
jobs via error codes 3/113).

Also adds _wait_for_gateway_exit() after bootout to ensure the process
is fully gone before returning, and tolerates 'already unloaded' errors.

Fixes: .env changes not taking effect after gateway stop+restart on macOS.
The root cause was that stop didn't actually stop — the respawned process
loaded the old env before the user's restart command ran.
2026-04-10 03:45:34 -07:00
Teknium
957485876b fix: update 6 test files broken by dead code removal
- test_percentage_clamp.py: remove TestContextCompressorUsagePercent class
  and test_context_compressor_clamped (tested removed get_status() method)
- test_credential_pool.py: remove test_mark_used_increments_request_count
  (tested removed mark_used()), replace active_lease_count() calls with
  direct _active_leases dict access, remove mark_used from thread test
- test_session.py: replace SessionSource.local_cli() factory calls with
  direct SessionSource construction (local_cli classmethod removed)
- test_error_classifier.py: remove test_is_transient_property (tested
  removed is_transient property on ClassifiedError)
- test_delivery.py: remove TestDeliveryRouter class (tested removed
  resolve_targets method), clean up unused imports
- test_skills_hub.py: remove test_is_hub_installed (tested removed
  is_hub_installed method on HubLockFile)
2026-04-10 03:44:43 -07:00
alt-glitch
c6c769772f fix: clean up stale test references to removed attributes 2026-04-10 03:44:43 -07:00
alt-glitch
f63cc3c0c7 chore: remove spec-dead-code.md from tracked files 2026-04-10 03:44:43 -07:00
alt-glitch
cff9b7ffab fix: restore 6 tests that tested live code but used deleted helpers 2026-04-10 03:44:43 -07:00
alt-glitch
96c060018a fix: remove 115 verified dead code symbols across 46 production files
Automated dead code audit using vulture + coverage.py + ast-grep intersection,
confirmed by Opus deep verification pass. Every symbol verified to have zero
production callers (test imports excluded from reachability analysis).

Removes ~1,534 lines of dead production code across 46 files and ~1,382 lines
of stale test code. 3 entire files deleted (agent/builtin_memory_provider.py,
hermes_cli/checklist.py, tests/hermes_cli/test_setup_model_selection.py).

Co-authored-by: alt-glitch <balyan.sid@gmail.com>
2026-04-10 03:44:43 -07:00
Teknium
04baab5422 fix(mcp): combine content and structuredContent when both present (#7118)
When an MCP server returns both content (model-oriented text) and
structuredContent (machine-oriented JSON), the client now combines
them instead of discarding content.  The text content becomes the
primary result (what the agent reads), and structuredContent is
included as supplementary metadata.

Previously, structuredContent took full precedence — causing data
loss for servers like Desktop Commander that put the actual file
text in content and metadata in structuredContent.

MCP spec guidance: for conversational/agent UX, prefer content.
2026-04-10 03:44:35 -07:00
tars
9a0dfb5a6d fix(gateway): scope /yolo to the active session 2026-04-10 03:38:44 -07:00
Teknium
68528068ec fix(streaming): update stale-stream timer during Anthropic native streaming (#7117)
The _call_anthropic() streaming path never updated last_chunk_time during
the event loop — only once at stream start. The stale stream detector in
the outer poll loop uses this timer, so any Anthropic stream longer than
180s was killed even when events were actively arriving. This self-inflicted
a RemoteProtocolError that users saw as:

  '⚠️ Connection to provider dropped (RemoteProtocolError). Reconnecting…'

The _call_chat_completions() path already updates last_chunk_time on every
chunk (line 4475). This brings _call_anthropic() to parity.

Also adds deltas_were_sent tracking to the Anthropic text_delta path so
the retry loop knows not to retry after partial delivery (prevents
duplicated output on connection drops mid-stream).

Reported-by: Discord users (Castellani, Codename_11)
2026-04-10 03:34:56 -07:00
Evi Nova
8dd738c2e6 fix(gateway): remap all paths in system service unit to target user's home
When installing a system service via sudo, ExecStart, WorkingDirectory,
VIRTUAL_ENV, and PATH entries were not remapped to the target user's
home — only HERMES_HOME was. This caused the service to fail with
status=200/CHDIR because the target user cannot access /root/.

Adds _remap_path_for_user() helper and applies it to all path variables
in the system branch of generate_systemd_unit().

Closes #6989
2026-04-10 03:30:36 -07:00
Teknium
0f597dd127 fix: STT provider-model mismatch — whisper-1 fed to faster-whisper (#7113)
Legacy flat stt.model config key (from cli-config.yaml.example and older
versions) was passed as a model override to transcribe_audio() by the
gateway, bypassing provider-specific model resolution. When the provider
was 'local' (faster-whisper), this caused:
  ValueError: Invalid model size 'whisper-1'

Changes:
- gateway/run.py, discord.py: stop passing model override — let
  transcribe_audio() handle provider-specific model resolution internally
- get_stt_model_from_config(): now provider-aware, reads from the correct
  nested section (stt.local.model, stt.openai.model, etc.); ignores
  legacy flat key for local provider to prevent model name mismatch
- cli-config.yaml.example: updated STT section to show nested provider
  config structure instead of legacy flat key
- config migration v13→v14: moves legacy stt.model to the correct
  provider section and removes the flat key

Reported by community user on Discord.
2026-04-10 03:27:30 -07:00
helix4u
5a8b5f149d fix(run-agent): rotate credential pool on billing-classified 400s 2026-04-10 03:27:19 -07:00
Teknium
f4f8b9579e fix: improve bluebubbles webhook registration resilience
Follow-up to cherry-picked PR #6592:
- Extract _webhook_url property to deduplicate URL construction
- Add _find_registered_webhooks() helper for reuse
- Crash resilience: check for existing registration before POSTing
  (handles restart after unclean shutdown without creating duplicates)
- Accept 200-299 status range (not just 200) for webhook creation
- Unregister removes ALL matching registrations (cleans up orphaned dupes)
- Add 17 tests covering register/unregister/find/edge cases
2026-04-10 03:21:45 -07:00
Osman Mehmood
c6ff5e5d30 fix(bluebubbles): auto-register webhook with BlueBubbles server on connect
**Problem:**
The BlueBubbles iMessage gateway was not receiving incoming messages even though:
1. BlueBubbles Server was properly configured and running
2. Hermes gateway started without errors
3. Webhook listener was started on the configured port

The root cause was that the BlueBubbles adapter only started a local webhook
listener but never registered the webhook URL with the BlueBubbles server via
the API. Without registration, the server doesn't know where to send events.

**Fix:**
1. Added _register_webhook() method that POSTs to /api/v1/webhook with the
   listener URL and event types (new-message, updated-message, message)
2. Added _unregister_webhook() method for clean shutdown
3. Both methods handle the case where webhook listens on 0.0.0.0/127.0.0.1
   by using 'localhost' as the external hostname
4. Fixed documentation: 'hermes gateway logs' → 'hermes logs gateway'

**API Reference:**
https://docs.bluebubbles.app/server/developer-guides/rest-api-and-webhooks

**Testing:**
- Webhook registration is now automatic when gateway starts
- Failed registration logs a warning but doesn't prevent startup
- Clean shutdown unregisters the webhook

Closes: iMessage gateway not working issue
2026-04-10 03:21:45 -07:00
helix4u
9aedab00f4 fix(run_agent): recover primary client on openai transport errors 2026-04-10 03:21:24 -07:00
maxyangcn
19292eb8bf feat(cron): support Discord thread_id in deliver targets
Add Discord thread support to cron delivery and send_message_tool.

- _parse_target_ref: handle discord platform with chat_id:thread_id format
- _send_discord: add thread_id param, route to /channels/{thread_id}/messages
- _send_to_platform: pass thread_id through for Discord
- Discord adapter send(): read thread_id from metadata for gateway path
- Update tool schema description to document Discord thread targets

Cherry-picked from PR #7046 by pandacooming (maxyangcn).

Follow-up fixes:
- Restore proxy support (resolve_proxy_url/proxy_kwargs_for_aiohttp) that was
  accidentally deleted — would have caused NameError at runtime
- Remove duplicate _DISCORD_TARGET_RE regex; reuse existing _TELEGRAM_TOPIC_TARGET_RE
  via _NUMERIC_TOPIC_RE alias (identical pattern)
- Fix misleading test comments about Discord negative snowflake IDs
  (Discord uses positive snowflakes; negative IDs are a Telegram convention)
- Rewrite misleading scheduler test that claimed to exercise home channel
  fallback but actually tested the explicit platform:chat_id parsing path
2026-04-10 03:20:05 -07:00
Teknium
6d5f607e48 fix: add all platforms to webhook cross-platform delivery
The delivery tuple in webhook.py only had 5 of 14 platforms with
gateway adapters. Adds whatsapp, matrix, mattermost, homeassistant,
email, dingtalk, feishu, wecom, and bluebubbles so webhooks can
deliver to any connected platform.

Updates docs delivery options table to list all platforms.

Follow-up to cherry-picked fix from olafthiele (PR #7035).
2026-04-10 03:16:24 -07:00
olafthiele
52bd3bd200 mattermost added as deliver to webhook gateway 2026-04-10 03:16:24 -07:00
Teknium
568be71003 fix: extract custom_provider_slug() helper, harden gateway test
- Add custom_provider_slug() to hermes_cli/providers.py as the single
  source of truth for building 'custom:<name>' slugs.
- Use it in resolve_custom_provider() and list_authenticated_providers()
  instead of duplicated inline slug construction.
- Add _session_model_overrides and _voice_mode to gateway test runner
  for object.__new__() safety.
2026-04-10 03:07:00 -07:00
donrhmexe
a2f46e4665 fix: include custom_providers in /model command listings and resolution
Custom providers defined in config.yaml under  were
completely invisible to the /model command in both gateway (Telegram,
Discord, etc.) and CLI. The provider listing skipped them and explicit
switching via --provider failed with "Unknown provider".

Root cause: gateway/run.py, cli.py, and model_switch.py only read the
 dict from config, ignoring  entirely.

Changes:
- providers.py: add resolve_custom_provider() and extend
  resolve_provider_full() to check custom_providers after user_providers
- model_switch.py: propagate custom_providers through switch_model(),
  list_authenticated_providers(), and get_authenticated_provider_slugs();
  add custom provider section to provider listings
- gateway/run.py: read custom_providers from config, pass to all
  model-switch calls
- cli.py: hoist config loading, pass custom_providers to listing and
  switch calls

Tests: 4 new regression tests covering listing, resolution, and gateway
command handler. All 71 tests pass.
2026-04-10 03:07:00 -07:00
Teknium
7d426e6536 test: update session ID tests to require auth (follow-up to #6930)
Session continuation now requires API_SERVER_KEY to be configured.
Update TestSessionIdHeader tests to use auth_adapter with Bearer token.
2026-04-10 03:05:04 -07:00
Teknium
30ae68dd33 fix: apply hidden_div regex newline bypass fix to skills_guard.py
The same .* pattern vulnerable to newline bypass that was fixed in
prompt_builder.py (PR #6925) also existed in skills_guard.py. Changed
to [\s\S]*? to match across newlines.
2026-04-10 03:05:04 -07:00
aaronagent
9afe1784bd fix: hidden_div regex bypass with newlines, credential config silent failure, webhook route error severity
prompt_builder.py: The `hidden_div` detection pattern uses `.*` which does not
match newlines in Python regex (re.DOTALL is not passed).  An attacker can bypass
detection by splitting the style attribute across lines:
  `<div style="color:red;\ndisplay: none">injected content</div>`
Replace `.*` with `[\s\S]*?` to match across line boundaries.

credential_files.py: `_load_config_files()` catches all exceptions at DEBUG level
(line 171), making YAML parse failures invisible in production logs.  Users whose
credential files silently fail to mount into sandboxes have no diagnostic clue.
Promote to WARNING to match the severity pattern used by the path validation
warnings at lines 150 and 158 in the same function.

webhook.py: `_reload_dynamic_routes()` logs JSON parse failures at WARNING (line
265) but the impact — stale/corrupted dynamic routes persisting silently — warrants
ERROR level to ensure operator visibility in alerting pipelines.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 03:05:04 -07:00
aaronagent
94f5979cc2 fix(approval,mcp): log silent exception handlers, narrow OAuth catches, close server on error
Three silent `except Exception` blocks in approval.py (lines 345, 387, 469) return
fallback values with zero logging — making it impossible to debug callback failures,
allowlist load errors, or config read issues.  Add logger.warning/error calls that
match the pattern already used by save_permanent_allowlist() and _smart_approve()
in the same file.

In mcp_oauth.py, narrow the overly-broad `except Exception` in get_tokens() and
get_client_info() to the specific exceptions Pydantic's model_validate() can raise
(ValueError, TypeError, KeyError), and include the exception message in the warning.
Also wrap the _wait_for_callback() polling loop in try/finally so the HTTPServer is
always closed — previously an asyncio.CancelledError or any exception in the loop
would leak the server socket.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 03:05:04 -07:00
aaronagent
738f0bac13 fix: align auth-by-message classification with status-code path, decode URLs before secret check
error_classifier.py: Message-only auth errors ("invalid api key", "unauthorized",
etc.) were classified as retryable=True (line 707), inconsistent with the HTTP 401
path (line 432) which correctly uses retryable=False + should_fallback=True.  The
mismatch causes 3 wasted retries with the same broken credential before fallback,
while 401 errors immediately attempt fallback.  Align the message-based path to
match: retryable=False, should_fallback=True.

web_tools.py: The _PREFIX_RE secret-detection check in web_extract_tool() runs
against the raw URL string (line 1196).  URL-encoded secrets like %73k-1234... (
sk-1234...) bypass the filter because the regex expects literal ASCII.  Add
urllib.parse.unquote() before the check so percent-encoded variants are also caught.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 03:05:04 -07:00
aaronagent
37bb4f807b fix(dingtalk,api): validate session webhook URL origin, cap webhook cache, reject header injection
dingtalk.py: The session_webhook URL from incoming DingTalk messages is POSTed to
without any origin validation (line 290), enabling SSRF attacks via crafted webhook
URLs (e.g. http://169.254.169.254/ to reach cloud metadata).  Add a regex check
that only accepts the official DingTalk API origin (https://api.dingtalk.com/).
Also cap _session_webhooks dict at 500 entries with FIFO eviction to prevent
unbounded memory growth from long-running gateway instances.

api_server.py: The X-Hermes-Session-Id request header is accepted and echoed back
into response headers (lines 675, 697) without sanitization.  A session ID
containing \r\n enables HTTP response splitting / header injection.  Add a check
that rejects session IDs containing control characters (\r, \n, \x00).

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 03:05:04 -07:00
Julien Talbot
b577697189 fix(model_metadata): add xAI Grok context length fallbacks
xAI /v1/models does not return context_length metadata, so Hermes
probes down to the 128k default whenever a user configures a custom
provider pointing at https://api.x.ai/v1. This forces every xAI user
to manually override model.context_length in config.yaml (2M for
Grok 4.20 / 4.1-fast / 4-fast) or lose most of the usable context
window.

Add DEFAULT_CONTEXT_LENGTHS entries for the Grok family so the
fallback lookup returns the correct value via substring matching.
Values sourced from models.dev (2026-04) and cross-checked against
the xAI /v1/models listing:

  - grok-4.20-*          2,000,000  (reasoning, non-reasoning, multi-agent)
  - grok-4-1-fast-*      2,000,000
  - grok-4-fast-*        2,000,000
  - grok-4 / grok-4-0709   256,000
  - grok-code-fast-1       256,000
  - grok-3*                131,072
  - grok-2 / latest        131,072
  - grok-2-vision*           8,192
  - grok (catch-all)       131,072

Keys are ordered longest-first so that specific variants match before
the catch-all, consistent with the existing Claude/Gemma/MiniMax entries.

Add TestDefaultContextLengths.test_grok_models_context_lengths and
test_grok_substring_matching to pin the values and verify the full
lookup path. All 77 tests in test_model_metadata.py pass.
2026-04-10 03:04:19 -07:00
Jeff Davis
5b22e61cfa feat(discord): add allowed_channels whitelist config
Add DISCORD_ALLOWED_CHANNELS (env var) / discord.allowed_channels (config.yaml)
support to restrict the bot to only respond in specified channels.

When set, messages from any channel NOT in the allowed list are silently
ignored — even if the bot is @mentioned. This provides a secure default-
deny posture vs the existing ignored_channels which is default-allow.

This is especially useful when bots in other channels may create new
channels dynamically (e.g., project bots) — a blacklist requires constant
maintenance while a whitelist is set-and-forget.

Follows the same config pattern as ignored_channels and free_response_channels:
- Env var: DISCORD_ALLOWED_CHANNELS (comma-separated channel IDs)
- Config: discord.allowed_channels (string or list of channel IDs)
- Env var takes precedence over config.yaml
- Empty/unset = no restriction (backward compatible)

Files changed:
- gateway/platforms/discord.py: check allowed_channels before ignored_channels
- gateway/config.py: map discord.allowed_channels → DISCORD_ALLOWED_CHANNELS
- hermes_cli/config.py: add allowed_channels to DEFAULT_CONFIG
2026-04-10 03:02:42 -07:00
Teknium
b39ea46488 fix(gateway): remove DM thread session seeding to prevent cross-thread contamination (#7084)
The session store was copying the ENTIRE parent DM transcript into new
thread sessions. This caused unrelated conversations to bleed across
threads in Slack DMs.

The Slack adapter already handles thread context correctly via
_fetch_thread_context() (conversations.replies API), which fetches
only the actual thread messages. The session-level seeding was both
redundant and harmful.

No other platform (Telegram, Discord) uses DM threads, so the seeding
code path was only triggered by Slack — where it conflicted with the
adapter-level context.

Tests updated to assert thread isolation: all thread sessions start
empty, platform adapters are responsible for injecting thread context.

Salvage of PR #5868 (jarvisxyz). Reported by norbert on Discord.
2026-04-10 03:01:59 -07:00
alt-glitch
aad40f6d0c fix(tests): update mocks for file sync changes
- Modal snapshot tests: accept **kw in iter_skills_files/iter_cache_files
  mock lambdas to match new container_base kwarg
- SSH preflight test: mock _detect_remote_home, _ensure_remote_dirs,
  init_session, and FileSyncManager added in file sync PR
2026-04-10 03:01:46 -07:00
alt-glitch
41c233cb99 test: add reproducible perf benchmark for file sync overhead
Direct env.execute() timing — no LLM in the loop.
Measures per-command wall-clock including sync check.

Results on SSH:
- echo median: 617ms (pure SSH round-trip + spawn overhead)
- sync-triggered after 6s wait: 621ms (mtime skip adds ~0ms)
- within-interval (no sync): 618ms

Confirms mtime skip makes sync overhead unmeasurable.
2026-04-10 03:01:46 -07:00
alt-glitch
1f1f297528 feat(environments): unified file sync with change tracking and deletion
Replace per-backend ad-hoc file sync with a shared FileSyncManager
that handles mtime-based change detection, remote deletion of
locally-removed files, and transactional state updates.

- New FileSyncManager class (tools/environments/file_sync.py)
  with callbacks for upload/delete, rate limiting, and rollback
- Shared iter_sync_files() eliminates 3 duplicate implementations
- SSH: replace unconditional rsync with scp + mtime skip
- Modal/Daytona: replace inline _synced_files dict with manager
- All 3 backends now sync credentials + skills + cache uniformly
- Remote deletion: files removed locally are cleaned from remote
- HERMES_FORCE_FILE_SYNC=1 env var for debugging
- Base class _before_execute() simplified to empty hook
- 12 unit tests covering mtime skip, deletion, rollback, rate limiting
2026-04-10 03:01:46 -07:00
buray
1495647636 fix(config): allow HERMES_HOME_MODE env var to override _secure_dir() permissions (#6993)
Operators running a web server (nginx, caddy) that needs to traverse ~/.hermes/ can now set HERMES_HOME_MODE=0701 (or any octal mode) instead of having _secure_dir() revert their manual chmod on every gateway restart. Default behavior (0o700) is unchanged. Fixes #6991. Contributed by @ygd58.
2026-04-10 03:00:15 -07:00
Teknium
4e78963fe8 fix(acp): remove dead nested usage dict path
run_conversation() never returns a result["usage"] nested dict —
token counters are always at the top level. The nested path used
the wrong key name ("cached_tokens" vs "cache_read_tokens") and
was never reachable. Remove it.
2026-04-10 03:00:12 -07:00
Yuhan Lei
f92298fe95 fix(acp): populate usage from top-level result fields 2026-04-10 03:00:12 -07:00
Kamil Gwóźdź
eaa21a8275 fix(copilot): add missing Copilot-Integration-Id header
The GitHub Copilot API now requires a Copilot-Integration-Id header
on all requests. Without it, every API call fails with HTTP 400:
"missing required Copilot-Integration-Id header".

Uses vscode-chat as the integration ID, matching opencode which
shares the same OAuth client ID (Ov23li8tweQw6odWQebz).

Fixes: Copilot provider fails with "missing required Copilot-Integration-Id header" (HTTP 400)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-10 02:59:02 -07:00
Teknium
a420235b66 fix: reject foreground timeout above cap instead of clamping
Change behavior from silent clamping to returning an error when the
model requests a foreground timeout exceeding FOREGROUND_MAX_TIMEOUT.
This forces the model to use background=true for long-running commands
rather than silently changing its intent.

- Config default timeouts above the cap are NOT rejected (user's choice)
- Only explicit model-requested timeouts trigger rejection
- Added boundary test for timeout exactly at the limit
2026-04-10 02:58:54 -07:00
kshitijk4poor
6c3565df57 fix(terminal): cap foreground timeout to prevent session deadlocks
When the model calls terminal() in foreground mode without background=true
(e.g. to start a server), the tool call blocks until the command exits or
the timeout expires. Without an upper bound the model can request arbitrarily
high timeouts (the schema had minimum=1 but no maximum), blocking the entire
agent session for hours until the gateway idle watchdog kills it.

Changes:
- Add FOREGROUND_MAX_TIMEOUT (600s, configurable via
  TERMINAL_MAX_FOREGROUND_TIMEOUT env var) that caps foreground timeout
- Clamp effective_timeout to the cap when background=false and timeout
  exceeds the limit
- Include a timeout_note in the tool result when clamped, nudging the
  model to use background=true for long-running processes
- Update schema description to show the max timeout value
- Remove dead clamping code in the background branch that could never
  fire (max_timeout was set to effective_timeout, so timeout > max_timeout
  was always false)
- Add 7 tests covering clamping, no-clamping, config-default-exceeds-cap
  edge case, background bypass, default timeout, constant value, and
  schema content

Self-review fixes:
- Fixed bug where timeout_note said 'Requested timeout Nones' when
  clamping fired from config default exceeding cap (timeout param is
  None). Now uses unclamped_timeout instead of the raw timeout param.
- Removed unused pytest import from test file
- Extracted test config dict into _make_env_config() helper
- Fixed tautological test_default_value assertion
- Added missing test for config default > cap with no model timeout
2026-04-10 02:58:54 -07:00
kshitijk4poor
51d826f889 fix(gateway): apply /model session overrides so switch persists across messages
The gateway /model command stored session overrides in
_session_model_overrides but run_sync() never consulted them when
resolving the model and runtime for the next message.  It always read
from config.yaml, so the switch was lost as soon as a new agent was
created.

Two fixes:

1. In run_sync(), apply _session_model_overrides after resolving from
   config.yaml/env — the override takes precedence for model, provider,
   api_key, base_url, and api_mode.

2. In post-run fallback detection, check whether the model mismatch
   (agent.model != config_model) is due to an intentional /model switch
   before evicting the cached agent.  Without this, the first message
   after /model would work (cached agent reused) but the fallback
   detector would evict it, causing the next message to revert.

Affects all gateway platforms (Telegram, Discord, Slack, WhatsApp,
Signal, Matrix, BlueBubbles, HomeAssistant) since they all share
GatewayRunner._run_agent().

Fixes #6213
2026-04-10 02:58:42 -07:00
coffee
a04854800f fix(security): require auth for session continuation and warn on missing API key
Two security hardening changes for the API server:

1. **Startup warning when no API key is configured.**
   When `API_SERVER_KEY` is not set, all endpoints accept unauthenticated
   requests.  This is the default configuration, but operators may not
   realize the security implications.  A prominent warning at startup
   makes the risk visible.

2. **Require authentication for session continuation.**
   The `X-Hermes-Session-Id` header allows callers to load and continue
   any session stored in state.db.  Without authentication, an attacker
   who can reach the API server (e.g. via CORS from a malicious page,
   or on a shared host) could enumerate session IDs and read conversation
   history — which may contain API keys, passwords, code, or other
   sensitive data shared with the agent.

   Session continuation now returns 403 when no API key is configured,
   with a clear error message explaining how to enable the feature.
   When a key IS configured, the existing Bearer token check already
   gates access.

This is defense-in-depth: the API server is intended for local use,
but defense against cross-origin and shared-host attacks is important
since the default binding is 127.0.0.1 which is reachable from
browsers via DNS rebinding or localhost CORS.
2026-04-10 02:58:21 -07:00
Young
940237c6fd fix(cli): prevent stale image attachment on text paste and voice input
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 02:58:18 -07:00
Teknium
95ee453bc0 docs: add cron script timeout and provider recovery documentation
- Add HERMES_CRON_TIMEOUT and HERMES_CRON_SCRIPT_TIMEOUT to env vars reference
- Add script timeout and provider recovery sections to cron features page
- Add timeout resolution chain and credential pool details to cron internals
2026-04-10 02:57:57 -07:00
Dominic Grieco
38cce22e2c fix: harden cron script timeout and provider recovery 2026-04-10 02:57:57 -07:00
Carlos
7368854398 Refresh OpenRouter model catalog
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-10 02:57:39 -07:00
Carlos
38ccd9eb95 Harden setup provider flows
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-10 02:57:39 -07:00
Cocoon-Break
45034b746f fix: set retryable=False for message-based auth errors in _classify_by_message() (#7027)
Auth errors matched by message pattern were incorrectly marked retryable=True, causing futile retry loops. Aligns with _classify_by_status() which already sets retryable=False for 401/403. Fixes #7026. Contributed by @kuishou68.
2026-04-10 02:48:45 -07:00
JiayuWang(王嘉宇)
a7588830d4 fix(cli): add missing os and platform imports in uninstall.py (#7034)
Fixes #6983. Contributed by @JiayuuWang.
2026-04-10 02:41:33 -07:00
kshitijk4poor
9431f82aff fix: update Kimi Coding User-Agent to KimiCLI/1.30.0
The hardcoded User-Agent 'KimiCLI/1.3' is outdated — Kimi CLI is now at
v1.30.0. The stale version string causes intermittent 403 errors from
Kimi's coding endpoint ('only available for Coding Agents').

Update all 8 occurrences across run_agent.py, auxiliary_client.py, and
doctor.py to 'KimiCLI/1.30.0' to match the current official Kimi CLI.
2026-04-10 02:37:28 -07:00
Teknium
6da952bc50 fix(gateway): /usage now shows rate limits, cost, and token details between turns (#7038)
The gateway /usage handler only looked in _running_agents for the agent
object, which is only populated while the agent is actively processing a
message. Between turns (when users actually type /usage), the dict is
empty and the handler fell through to a rough message-count estimate.

The agent object actually lives in _agent_cache between turns (kept for
prompt caching). This fix checks both dicts, with _running_agents taking
priority (mid-turn) and _agent_cache as the between-turns fallback.

Also brings the gateway output to parity with the CLI /usage:
- Model name
- Detailed token breakdown (input, output, cache read, cache write)
- Cost estimation (estimated amount or 'included' for subscriptions)
- Cache token lines hidden when zero (cleaner output)

This fixes Nous Portal rate limit headers not showing up for gateway
users — the data was being captured correctly but the handler could
never see it.
2026-04-10 02:33:01 -07:00
Teknium
8779a268a7 feat: add Anthropic Fast Mode support to /fast command (#7037)
Extends the /fast command to support Anthropic's Fast Mode beta in addition
to OpenAI Priority Processing. When enabled on Claude Opus 4.6, adds
speed:"fast" and the fast-mode-2026-02-01 beta header to API requests for
~2.5x faster output token throughput.

Changes:
- hermes_cli/models.py: Add _ANTHROPIC_FAST_MODE_MODELS registry,
  model_supports_fast_mode() now recognizes Claude Opus 4.6,
  resolve_fast_mode_overrides() returns {speed: fast} for Anthropic
  vs {service_tier: priority} for OpenAI
- agent/anthropic_adapter.py: Add _FAST_MODE_BETA constant,
  build_anthropic_kwargs() accepts fast_mode=True which injects
  speed:fast + beta header via extra_headers (skipped for third-party
  Anthropic-compatible endpoints like MiniMax)
- run_agent.py: Pass fast_mode to build_anthropic_kwargs in the
  anthropic_messages path of _build_api_kwargs()
- cli.py: Update _handle_fast_command with provider-aware messaging
  (shows 'Anthropic Fast Mode' vs 'Priority Processing')
- hermes_cli/commands.py: Update /fast description to mention both
  providers
- tests: 13 new tests covering Anthropic model detection, override
  resolution, CLI availability, routing, adapter kwargs, and
  third-party endpoint safety
2026-04-10 02:32:15 -07:00
Teknium
0848a79476 fix(update): always reset on stash conflict — never leave conflict markers (#7010)
When `hermes update` stashes local changes and the restore hits merge
conflicts, the old code prompted the user to reset or keep conflict
markers.  If the user declined the reset, git conflict markers
(<<<<<<< Updated upstream) were left in source files, making hermes
completely unrunnable with a SyntaxError on the next invocation.

Additionally, the interactive path called sys.exit(1), which killed
the entire update process before pip dependency install, skill sync,
and gateway restart could finish — even though the code pull itself
had succeeded.

Changes:
- Always auto-reset to clean state when stash restore conflicts
- Remove the "Reset working tree?" prompt (footgun)
- Remove sys.exit(1) — return False so cmd_update continues normally
- User's changes remain safely in the stash for manual recovery

Also fixes a secondary bug where the conflict handling prompt used
bare input() instead of the input_fn parameter, which would hang
in gateway mode.

Tests updated: replaced prompt/sys.exit assertions with auto-reset
behavior checks; removed the "user declines reset" test (path no
longer exists).
2026-04-10 00:32:20 -07:00
Teknium
871313ae2d fix: clear conversation_history after mid-loop compression to prevent empty sessions (#7001)
After mid-loop compression (triggered by 413, context_overflow, or Anthropic
long-context tier errors), _compress_context() creates a new session in SQLite
and resets _last_flushed_db_idx=0. However, conversation_history was not cleared,
so _flush_messages_to_session_db() computed:

    flush_from = max(len(conversation_history=200), _last_flushed_db_idx=0) = 200
    messages[200:] → empty (compressed messages < 200)

This resulted in zero messages being written to the new session's SQLite store.
On resume, the user would see 'Session found but has no messages.'

The preflight compression path (line 7311) already had the fix:
    conversation_history = None

This commit adds the same clearing to the three mid-loop compression sites:
- Anthropic long-context tier overflow
- HTTP 413 payload too large
- Generic context_overflow error

Reported by Aaryan (Nous community).
2026-04-10 00:14:59 -07:00
Teknium
13d7ff3420 fix(gateway): bypass text batching when delay is 0 (#6996)
The text batching feature routes TEXT messages through
asyncio.create_task() + asyncio.sleep(delay). Even with delay=0,
the task fires asynchronously and won't complete before synchronous
test assertions. This broke 33 tests across Discord, Matrix, and
WeCom adapters.

When _text_batch_delay_seconds is 0 (the test fixture setting),
dispatch directly to handle_message() instead of going through
the async batching path. This preserves the pre-batching behavior
for tests while keeping batching active in production (default
delay 0.6s).
2026-04-09 23:59:20 -07:00
Teknium
d5023d36d8 docs: document streaming timeout auto-detection for local LLMs (#6990)
Add streaming timeout documentation to three pages:

- guides/local-llm-on-mac.md: New 'Timeouts' section with table of all
  three timeouts, their defaults, local auto-adjustments, and env var
  overrides
- reference/faq.md: Tip box in the local models FAQ section
- user-guide/configuration.md: 'Streaming Timeouts' subsection under
  the agent config section

Follow-up to #6967.
2026-04-09 23:28:25 -07:00
Sahil
0602ff8f58 fix(docker): use uv for dependency resolution to fix resolution-too-deep error 2026-04-09 23:25:56 -07:00
Teknium
8104f400f8 test: disable text batching in existing adapter tests
Set _text_batch_delay_seconds = 0 on test adapter fixtures so messages
dispatch immediately (bypassing async batching). This preserves the
existing synchronous assertion patterns while the batching logic is
tested separately in test_text_batching.py.
2026-04-09 23:25:27 -07:00
Teknium
1ed00496f2 test: add text batching tests for Discord, Matrix, WeCom, Telegram, Feishu
22 tests covering:
- Single message dispatch after delay
- Split message aggregation (2-way and 3-way)
- Different chats/rooms not merged
- Adaptive delay for near-limit chunks
- State cleanup after flush
- Split continuation merging

All 5 platform adapters tested.
2026-04-09 23:25:27 -07:00
Teknium
f92a0b8596 fix(feishu): add adaptive batch delay for split long messages
Feishu already had text batching with a static 0.6s delay. This adds
adaptive delay: waits 2.0s when a chunk is near the ~4096-char split
point since a continuation is almost certain.

Tracks _last_chunk_len on each queued event to determine the delay.
Configurable via HERMES_FEISHU_TEXT_BATCH_SPLIT_DELAY_SECONDS (default 2.0).

Ref #6892
2026-04-09 23:25:27 -07:00
Teknium
1723e8e998 fix(wecom): add text batching to merge split long messages
Ports the adaptive batching pattern from the Telegram adapter.
WeCom clients split messages around 4000 chars. Adaptive delay waits
2.0s when a chunk is near the limit, 0.6s otherwise. Only text messages
are batched; commands/media dispatch immediately.

Ref #6892
2026-04-09 23:25:27 -07:00
Teknium
07148cac9a fix(matrix): add text batching to merge split long messages
Ports the adaptive batching pattern from the Telegram adapter.
Matrix clients split messages around 4000 chars. Adaptive delay waits
2.0s when a chunk is near the limit, 0.6s otherwise. Only text messages
are batched; commands dispatch immediately.

Ref #6892
2026-04-09 23:25:27 -07:00
Teknium
0fc0c1c83b fix(discord): add text batching to merge split long messages
Cherry-picked from PR #6894 by SHL0MS with fixes:
- Only batch TEXT messages; commands/media dispatch immediately
- Use build_session_key() for proper session-scoped batch keys
- Consistent naming (_text_batch_delay_seconds)
- Proper Dict[str, MessageEvent] typing

Discord splits at 2000 chars (lowest of all platforms). Adaptive delay
waits 2.0s when a chunk is near the limit, 0.6s otherwise.
2026-04-09 23:25:27 -07:00
Teknium
5075717949 fix(telegram): adaptive batch delay for split long messages
Cherry-picked from PR #6891 by SHL0MS.
When a chunk is near the 4096-char split point, wait 2.0s instead of 0.6s
since a continuation is almost certain.
2026-04-09 23:25:27 -07:00
Teknium
f783986f5a fix: increase stream read timeout default to 120s, auto-raise for local LLMs (#6967)
Raise the default httpx stream read timeout from 60s to 120s for all
providers. Additionally, auto-detect local LLM endpoints (Ollama,
llama.cpp, vLLM) and raise the read timeout to HERMES_API_TIMEOUT
(1800s) since local models can take minutes for prefill on large
contexts before producing the first token.

The stale stream timeout already had this local auto-detection pattern;
the httpx read timeout was missing it — causing a hard 60s wall that
users couldn't find (HERMES_STREAM_READ_TIMEOUT was undocumented).

Changes:
- Default HERMES_STREAM_READ_TIMEOUT: 60s -> 120s
- Auto-detect local endpoints -> raise to 1800s (user override respected)
- Document HERMES_STREAM_READ_TIMEOUT and HERMES_STREAM_STALE_TIMEOUT
- Add 10 parametrized tests

Reported-by: Pavan Srinivas (@pavanandums)
2026-04-09 22:35:30 -07:00
emozilla
bda9aa17cb fix(streaming): prevent <think> in prose from suppressing response output
When the model mentions <think> as literal text in its response (e.g.
"(/think not producing <think> tags)"), the streaming display treated it
as a reasoning block opener and suppressed everything after it. The
response box would close with truncated content and no error — the API
response was complete but the display ate it.

Root cause: _stream_delta() matched <think> anywhere in the text stream
regardless of position. Real reasoning blocks always start at the
beginning of a line; mentions in prose appear mid-sentence.

Fix: track line position across streaming deltas with a
_stream_last_was_newline flag. Only enter reasoning suppression when
the tag appears at a block boundary (start of stream, after a newline,
or after only whitespace on the current line). Add a _flush_stream()
safety net that recovers buffered content if no closing tag is found
by end-of-stream.

Also fixes three related issues discovered during investigation:

- anthropic_adapter: _get_anthropic_max_output() now normalizes dots to
  hyphens so 'claude-opus-4.6' matches the 'claude-opus-4-6' table key
  (was returning 32K instead of 128K)

- run_agent: send explicit max_tokens for Claude models on Nous Portal,
  same as OpenRouter — both proxy to Anthropic's API which requires it.
  Without it the backend defaults to a low limit that truncates responses.

- run_agent: reset truncated_tool_call_retries after successful tool
  execution so a single truncation doesn't poison the entire conversation.
2026-04-09 22:16:36 -07:00
Teknium
8394b5ddd2 feat: expand /fast to all OpenAI Priority Processing models (#6960)
Previously /fast only supported gpt-5.4 and forced a provider switch to
openai-codex. Now supports all 13 models from OpenAI's Priority Processing
pricing table (gpt-5.4, gpt-5.4-mini, gpt-5.2, gpt-5.1, gpt-5, gpt-5-mini,
gpt-4.1, gpt-4.1-mini, gpt-4.1-nano, gpt-4o, gpt-4o-mini, o3, o4-mini).

Key changes:
- Replaced _FAST_MODE_BACKEND_CONFIG with _PRIORITY_PROCESSING_MODELS frozenset
- Removed provider-forcing logic — service_tier is now injected into whatever
  API path the user is already on (Codex Responses, Chat Completions, or
  OpenRouter passthrough)
- Added request_overrides support to chat_completions path in run_agent.py
- Updated messaging from 'Codex inference tier' to 'Priority Processing'
- Expanded test coverage for all supported models
2026-04-09 22:06:30 -07:00
g-guthrie
d416a69288 feat: add Codex fast mode toggle (/fast command)
Add /fast slash command to toggle OpenAI Codex service_tier between
normal and priority ('fast') inference. Only exposed for models
registered in _FAST_MODE_BACKEND_CONFIG (currently gpt-5.4).

- Registry-based backend config for extensibility
- Dynamic command visibility (hidden from help/autocomplete for
  non-supported models) via command_filter on SlashCommandCompleter
- service_tier flows through request_overrides from route resolution
- Omit max_output_tokens for Codex backend (rejects it)
- Persists to config.yaml under agent.service_tier

Salvage cleanup: removed simple_term_menu/input() menu (banned),
bare /fast now shows status like /reasoning. Removed redundant
override resolution in _build_api_kwargs — single source of truth
via request_overrides from route.

Co-authored-by: Hermes Agent <hermes@nousresearch.com>
2026-04-09 21:54:32 -07:00
Teknium
4caa635803 fix: add auth.json write-back for Codex retry and valid-token early-return paths
The Codex retry block and valid-token short-circuit in _refresh_entry()
both return early, bypassing the auth.json sync at the end of the method.
This adds _sync_device_code_entry_to_auth_store() calls on both paths
so refreshed/synced tokens are written back to auth.json regardless of
which code path succeeds.
2026-04-09 21:48:50 -07:00
Ben Barclay
a64d8a83e1 fix: proactive Codex CLI sync before refresh + retry on failure 2026-04-09 21:48:50 -07:00
Ben Barclay
dfde4058cf fix: sync refreshed OAuth tokens from pool back to auth.json providers 2026-04-09 21:48:50 -07:00
Ben Barclay
13b3ea6484 fix: skip stale Nous pool entry when agent_key is expired 2026-04-09 21:48:50 -07:00
SHL0MS
941608cdde feat(skills): add creative divergence strategies for experimental output
Adds opt-in creative thinking frameworks to ascii-video, p5js, and
manim-video skills, based on Lluminate (joelsimon.net/lluminate).

Only engaged when the user explicitly asks for creative, experimental,
or unconventional output. Straightforward requests are unaffected.

Each skill gets 2-3 strategies matched to its domain:
- ascii-video: Forced Connections, Conceptual Blending, Oblique Strategies
- p5js: Conceptual Blending, SCAMPER, Distance Association
- manim-video: SCAMPER, Assumption Reversal

Strategies sourced from creativity research (Boden, Eno, de Bono,
Koestler, Fauconnier & Turner, Osborn), formalized for LLM prompting
by Lluminate.
2026-04-09 21:40:16 -04:00
Teknium
b87d00288d fix: add actionable hint for OpenRouter 'no tool endpoints' error
When OpenRouter returns 'No endpoints found that support tool use'
(HTTP 404), display a hint explaining that provider routing restrictions
may be filtering out tool-capable providers. Links the user directly
to the model's OpenRouter page to check which providers support tools.

The hint fires in the error display block that runs regardless of whether
fallback succeeds — so the user always understands WHY the model failed,
not just that it fell back.

Reported via Discord: GLM-5.1 on OpenRouter with US-based provider
restrictions eliminated all 4 tool-supporting endpoints (DeepInfra,
Z.AI, Friendli, Venice), leaving only 7 non-tool providers.
2026-04-09 18:03:09 -07:00
kshitijk4poor
08e2a1a51e fix(anthropic): omit tool-streaming beta on MiniMax endpoints
MiniMax's Anthropic-compatible endpoints reject requests that include
the fine-grained-tool-streaming beta header — every tool-use message
triggers a connection error (~18s timeout). Regular chat works fine.

Add _common_betas_for_base_url() that filters out the tool-streaming
beta for Bearer-auth (MiniMax) endpoints while keeping all other betas.
All four client-construction branches now use the filtered list.

Based on #6528 by @HiddenPuppy.
Original cherry-picked from PR #6688 by kshitijk4poor.
Fixes #6510, fixes #6555.
2026-04-09 17:53:52 -07:00
Teknium
9634e20e15 feat: API server model name derived from profile name (#6857)
* feat: API server model name derived from profile name

For multi-user setups (e.g. OpenWebUI), each profile's API server now
advertises a distinct model name on /v1/models:

- Profile 'lucas' -> model ID 'lucas'
- Profile 'admin' -> model ID 'admin'
- Default profile -> 'hermes-agent' (unchanged)

Explicit override via API_SERVER_MODEL_NAME env var or
platforms.api_server.model_name config for custom names.

Resolves friction where OpenWebUI couldn't distinguish multiple
hermes-agent connections all advertising the same model name.

* docs: multi-user setup with profiles for API server + Open WebUI

- api-server.md: added Multi-User Setup section, API_SERVER_MODEL_NAME
  to config table, updated /v1/models description
- open-webui.md: added Multi-User Setup with Profiles section with
  step-by-step guide, updated model name references
- environment-variables.md: added API_SERVER_MODEL_NAME entry
2026-04-09 17:07:29 -07:00
AIandI0x1
2d0d05a337 fix(agent): detect truncated streaming tool calls before execution
When a streaming response is cut mid-tool-call (connection drop, timeout),
the accumulated function.arguments is invalid JSON. The mock response
builder defaulted finish_reason to 'stop', so the agent loop treated it
as a valid completed turn and tried to execute tools with broken args.

Fix: validate tool call arguments with json.loads() during mock response
reconstruction. If any are invalid JSON, override finish_reason to
'length'. In the main loop's length handler, if tool calls are present,
refuse to execute and return partial=True with a clear error instead of
silently failing or wasting retries.

Also fixes _thinking_exhausted to not short-circuit when tool calls are
present — truncated tool calls are not thinking exhaustion.

Original cherry-picked from PR #6776 by AIandI0x1.
Closes #6638.
2026-04-09 17:03:54 -07:00
Teknium
3b554bf839 fix: test for suppress_status_output should capture stdout, not mock _vprint
The test was mocking _vprint entirely, bypassing the suppress guard.
Switch to capturing _print_fn output so the real _vprint runs and
the guard suppresses retry noise as intended.
2026-04-09 16:24:53 -07:00
Teknium
69a0092c38 fix: deduplicate _is_termux() into hermes_constants.is_termux()
Replace 6 identical copies of the Termux detection function across
cli.py, browser_tool.py, voice_mode.py, status.py, doctor.py, and
gateway.py with a single shared implementation in hermes_constants.py.

Each call site imports with its original local name to preserve all
existing callers (internal references and test monkeypatches).
2026-04-09 16:24:53 -07:00
adybag14-cyber
c3141429b7 fix(termux): tighten voice setup and mobile chat UX 2026-04-09 16:24:53 -07:00
adybag14-cyber
769ec1ee1a fix(termux): deepen browser, voice, and tui support 2026-04-09 16:24:53 -07:00
adybag14-cyber
3237733ca5 fix(termux): harden execute_code and mobile browser/audio UX 2026-04-09 16:24:53 -07:00
adybag14-cyber
54d5138a54 fix(termux): harden env-backed background jobs 2026-04-09 16:24:53 -07:00
adybag14-cyber
6dcb3c4774 fix(termux): compact narrow-screen tui chrome 2026-04-09 16:24:53 -07:00
adybag14-cyber
096b3f9f12 fix(termux): add local image chat route 2026-04-09 16:24:53 -07:00
adybag14-cyber
a3aed1bd26 fix(termux): keep quiet chat output parseable 2026-04-09 16:24:53 -07:00
adybag14-cyber
4970705ed3 fix(termux): silence quiet chat tool previews 2026-04-09 16:24:53 -07:00
adybag14-cyber
2194425918 fix(termux): make setup-hermes use android path 2026-04-09 16:24:53 -07:00
adybag14-cyber
3878495972 fix(termux): disable gateway service flows on android 2026-04-09 16:24:53 -07:00
adybag14-cyber
4e40e93b98 fix(termux): improve status and install UX 2026-04-09 16:24:53 -07:00
adybag14-cyber
122925a6f2 fix(termux): honor temp dirs for local temp artifacts 2026-04-09 16:24:53 -07:00
adybag14-cyber
e79cc88985 feat: add tested Termux install path and EOF-aware gh auth 2026-04-09 16:24:53 -07:00
sprmn24
e053433c84 fix(error_classifier): disambiguate usage-limit patterns in _classify_by_message
_classify_by_message had no handling for _USAGE_LIMIT_PATTERNS, so
messages like 'usage limit exceeded, try again in 5 minutes' arriving
without an HTTP status code fell through to FailoverReason.unknown
instead of rate_limit.

Apply the same billing/rate-limit disambiguation that _classify_402
already uses: USAGE_LIMIT_PATTERNS + transient signal → rate_limit,
USAGE_LIMIT_PATTERNS alone → billing.

Add 4 tests covering the no-status-code usage-limit path.
2026-04-09 16:24:13 -07:00
Siddharth Balyan
1789c2699a feat(nix): shared-state permission model for interactive CLI users (#6796)
* feat(nix): shared-state permission model for interactive CLI users

Enable interactive CLI users in the hermes group to share full
read-write state (sessions, memories, logs, cron) with the gateway
service via a setgid + group-writable permission model.

Changes:

nix/nixosModules.nix:
- Directories use setgid 2770 (was 0750) so new files inherit the
  hermes group. home/ stays 0750 (no interactive write needed).
- Activation script creates HERMES_HOME subdirs (cron, sessions, logs,
  memories) — previously Python created them but managed mode now skips
  mkdir.
- Activation migrates existing runtime files to group-writable (chmod
  g+rw). Nix-managed files (config.yaml, .env, .managed) stay 0640/0644.
- Gateway systemd unit gets UMask=0007 so files it creates are 0660.

hermes_cli/config.py:
- ensure_hermes_home() splits into managed/unmanaged paths. Managed mode
  verifies dirs exist (raises RuntimeError if not) instead of creating
  them. Scoped umask(0o007) ensures SOUL.md is created as 0660.

hermes_logging.py:
- _ManagedRotatingFileHandler subclass applies chmod 0660 after log
  rotation in managed mode. RotatingFileHandler.doRollover() creates new
  files via open() which uses the process umask (0022 → 0644), not the
  scoped umask from ensure_hermes_home().

Verified with a 13-subtest NixOS VM integration test covering setgid,
interactive writes, file ownership, migration, and gateway coexistence.

Refs: #6044

* Fix managed log file mode on initial open

Co-authored-by: Siddharth Balyan <alt-glitch@users.noreply.github.com>

* refactor: simplify managed file handler and merge activation loops

- Cache is_managed() result in handler __init__ instead of lazy-importing
  on every _open()/_chmod_if_managed() call. Avoids repeated stat+env
  checks on log rotation.
- Merge two for-loops over the same subdir list in activation script
  into a single loop (mkdir + chown + chmod + find in one pass).

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Siddharth Balyan <alt-glitch@users.noreply.github.com>
2026-04-10 03:48:42 +05:30
dangelo352
aed9b90ae3 fix(stream_consumer): handle overflow when no message exists yet
The overflow split loop required _message_id to be set, but on the
first streamed message (or after a segment break) _message_id is None.
Oversized text fell through to _send_or_edit → adapter.send(), which
split internally — but subsequent edits hit Telegram's 'message too
long' and were silently truncated with '…', cutting off the response.

Add a new code path for the _message_id is None case that uses
truncate_message() (same as the non-streaming path) to split with
proper word/code-fence boundaries and chunk indicators. Each chunk
is sent as a new message via _send_new_chunk().

Properly handles got_done (returns immediately after sending chunks
instead of continuing into an infinite loop) and got_segment_break.

Original cherry-picked from PR #6816 by dangelo352.

Fixes silent message truncation on Telegram for long streamed responses.
2026-04-09 15:07:21 -07:00
Teknium
6b437f7934 fix: /browser connect auto-launch uses dedicated profile dir (#6821)
Chrome auto-launch now passes --user-data-dir, --no-first-run, and
--no-default-browser-check so the debug instance doesn't conflict with
an already-running Chrome using the default profile. The profile dir
lives at {hermes_home}/chrome-debug/.

Also updates the fallback manual instructions to include the same flags
and removes the stale 'close existing Chrome windows' hint.
2026-04-09 14:55:45 -07:00
Teknium
f91fffbe33 Revert "fix: /browser connect auto-launch uses dedicated profile dir"
This reverts commit c3854e0f85.
2026-04-09 14:54:37 -07:00
Teknium
49d8c9557f fix: cleanup_all_camofox_sessions respects managed persistence (#6820)
When managed_persistence is enabled, cleanup_all now only clears local
tracking state without sending DELETE requests to the Camofox server.
This prevents persistent browser profiles (cookies, logins, localStorage)
from being destroyed during process-wide cleanup.

Ephemeral sessions still get full server-side deletion as before.
2026-04-09 14:54:07 -07:00
Teknium
c3854e0f85 fix: /browser connect auto-launch uses dedicated profile dir
Chrome auto-launch now passes --user-data-dir, --no-first-run, and
--no-default-browser-check so the debug instance doesn't conflict with
an already-running Chrome using the default profile. The profile dir
lives at {hermes_home}/chrome-debug/.

Also updates the fallback manual instructions to include the same flags
and removes the stale 'close existing Chrome windows' hint.
2026-04-09 14:52:58 -07:00
Teknium
97308707e9 fix: insert static fallback when compression summary fails
When _generate_summary() failed (no provider, timeout, model error),
the compressor silently dropped all middle turns with just a debug
log. The agent would then see head + tail with no explanation of the
gap, causing total context amnesia (generic greetings instead of
continuing the conversation).

Now generates a static fallback marker that tells the model context
was lost and to continue from the recent tail messages. The fallback
flows through the same role-alternation logic as a real summary so
message structure stays valid.
2026-04-09 14:28:56 -07:00
Teknium
e9168f917e fix: handle HTTP errors gracefully in gws_bridge token refresh
Instead of crashing with a raw urllib traceback on refresh failure,
print a clean error message and suggest re-running setup.py.
2026-04-09 14:28:35 -07:00
Teknium
c8bbd29aae fix: update tests for gws migration
- Rewrite test_google_workspace_api.py: test bridge token handling
  and calendar date range instead of removed get_credentials()
- Update test_google_oauth_setup.py: partial scopes now accepted
  with warning instead of rejected with SystemExit
2026-04-09 14:28:35 -07:00
Teknium
73eb59db8d fix: follow-up fixes for google-workspace gws migration
- Fix npm package name: @anthropic -> @googleworkspace/cli
- Add Homebrew install option
- Fix calendar_list to respect --start/--end args (uses raw Calendar
  API for date ranges, +agenda helper for default 7-day view)
- Improve check_auth partial scope output (list missing scopes)
- Add output format documentation with key JSON shapes
- Use npm install in troubleshooting (no Rust toolchain needed)

Follow-up to cherry-picked PR #6713
2026-04-09 14:28:35 -07:00
spideystreet
127b4caf0d feat(skills): migrate google-workspace to gws CLI backend
Migrate the google-workspace skill from custom Python API wrappers
(google-api-python-client) to Google's official Rust CLI gws
(googleworkspace/cli). Add gws_bridge.py for headless-compatible
token refresh. Fix partial OAuth scope handling.

Co-authored-by: spideystreet <dhicham.pro@gmail.com>
Cherry-picked from PR #6713
2026-04-09 14:28:35 -07:00
Teknium
1780ad24b1 fix: normalize remaining reasoning effort orderings and add missing 'minimal'
Follow-up to cherry-picked PR #6698. Fixes spots the original PR missed:
- hermes_constants.py: VALID_REASONING_EFFORTS tuple ordering
- gateway/run.py: _load_reasoning_config docstring + validation tuple
- configuration.md and batch-processing.md: docs ordering
- hermes-agent skill: /reasoning usage hint was missing 'minimal'
2026-04-09 14:20:16 -07:00
Greer Guthrie
775a46ce75 fix: normalize reasoning effort ordering in UI 2026-04-09 14:20:16 -07:00
Teknium
6f8e426275 fix: add SOCKS proxy support, DISCORD_PROXY env var, and send_message proxy coverage
Follow-up improvements on top of the shared resolver from PR #6562:

- Add platform_env_var parameter to resolve_proxy_url() so DISCORD_PROXY
  takes priority over generic HTTPS_PROXY/ALL_PROXY env vars
- Add SOCKS proxy support via aiohttp_socks.ProxyConnector with rdns=True
  (critical for GFW/Shadowrocket/Clash users — issue #6649)
- proxy_kwargs_for_bot() returns connector= for SOCKS, proxy= for HTTP
- proxy_kwargs_for_aiohttp() returns split (session_kw, request_kw) for
  standalone aiohttp sessions
- Add proxy support to send_message_tool.py (Discord REST, Slack, SMS)
  for cron job delivery behind proxies (from PR #2208)
- Add proxy support to Discord image/document downloads
- Fix duplicate import sys in base.py
2026-04-09 14:19:06 -07:00
Zheng Li
88dbbfe982 feat(gateway): unified proxy support for Discord and Telegram with macOS auto-detection
- Add resolve_proxy_url() to base.py — shared by all platform adapters
- Check HTTPS_PROXY / HTTP_PROXY / ALL_PROXY env vars first
- Fall back to macOS system proxy via scutil --proxy (zero-config)
- Pass proxy= to discord.py commands.Bot() for gateway connectivity
- Refactor telegram_network.py to use shared resolver
- Update test fixtures to accept proxy kwarg
2026-04-09 14:19:06 -07:00
jarvisxyz
88845b99d2 fix(slack): add rate-limit retry and TTL cache to thread context fetching
- Add _ThreadContextCache dataclass for caching fetched context (60s TTL)
- Add exponential backoff retry for conversations.replies 429 rate limits
  (Tier 3, ~50 req/min)
- Only fetch context when no active session exists (guard at call site)
  to prevent duplication across turns
- Hoist bot_uid lookup outside the per-message loop
- Clearer header text for injected thread context

Based on PR #6162 by jarvisxyz, cherry-picked onto current main.
2026-04-09 14:07:32 -07:00
gunpowder-client-vm
18d8e91a5a fix(slack): treat group DMs (mpim) like DMs + smart reaction guard
- Treat mpim (multi-party IM / group DM) channels as DMs — no @mention
  required, continuous session like 1:1 DMs
- Only add 👀/ reactions when bot is directly addressed (DM or
  @mention). In listen-all channels (require_mention=false) reacting
  to every message would be noisy.

Based on PR #4633 by gunpowder-client-vm, adapted to current main.
2026-04-09 14:07:32 -07:00
Mibayy
1773e3d647 feat(slack): add allow_bots config for bot-to-bot communication
Three modes: "none" (default, backward-compatible), "mentions" (accept
bot messages only when they @mention us), "all" (accept all bot messages
except our own, to prevent echo loops).

Configurable via:
  slack:
    allow_bots: mentions
Or env var: SLACK_ALLOW_BOTS=mentions

Self-message guard always active regardless of mode.

Based on PR #3200 by Mibayy, adapted to current main with config.yaml
bridging support.
2026-04-09 14:07:32 -07:00
dashed
7f7b02b764 fix(slack): comprehensive mrkdwn formatting — 6 bug fixes + 52 tests
Fixes blockquote > escaping, edit_message raw markdown, ***bold italic***
handling, HTML entity double-escaping (&amp;amp;), Wikipedia URL parens
truncation, and step numbering format. Also adds format_message to the
tool-layer _send_to_platform for consistent formatting across all
delivery paths.

Changes:
- Protect Slack entities (<@user>, <https://...|label>, <!here>) from
  escaping passes
- Protect blockquote > markers before HTML entity escaping
- Unescape-before-escape for idempotent HTML entity handling
- ***bold italic*** → *_text_* conversion (before **bold** pass)
- URL regex upgraded to handle balanced parentheses
- mrkdwn:True flag on chat_postMessage payloads
- format_message applied in edit_message and send_message_tool
- 52 new tests (format, edit, streaming, splitting, tool chunking)
- Use reversed(dict) idiom for placeholder restoration

Based on PR #3715 by dashed, cherry-picked onto current main.
2026-04-09 14:07:32 -07:00
Doruk Ardahan
7d499c75db feat(slack): add require_mention and free_response_channels config support
Port the mention gating pattern from Telegram, Discord, WhatsApp, and
Matrix adapters to the Slack platform adapter.

- Add _slack_require_mention() with explicit-false parsing and env var
  fallback (SLACK_REQUIRE_MENTION)
- Add _slack_free_response_channels() with env var fallback
  (SLACK_FREE_RESPONSE_CHANNELS)
- Replace hardcoded mention check with configurable gating logic
- Bridge slack config.yaml settings to env vars
- Bridge free_response_channels through the generic platform bridging loop
- Add 26 tests covering config parsing, env fallback, gating logic

Config usage:
  slack:
    require_mention: false
    free_response_channels:
      - "C0AQWDLHY9M"

Default behavior unchanged: channels require @mention (backward compatible).

Based on PR #5885 by dorukardahan, cherry-picked and adapted to current main.
2026-04-09 14:07:32 -07:00
Teknium
997e219c14 fix(security): enforce user authorization on approval button clicks
Approval button clicks (Block Kit actions in Slack, CallbackQuery in
Telegram) bypass the normal message authorization flow in gateway/run.py.
Any workspace/group member who can see the approval message could click
Approve to authorize dangerous commands.

Read SLACK_ALLOWED_USERS / TELEGRAM_ALLOWED_USERS env vars directly in
the approval handlers. When an allowlist is configured and the clicking
user is not in it, the click is silently ignored (Slack) or answered
with an error (Telegram). Wildcard '*' permits all users. When no
allowlist is configured, behavior is unchanged (open access).

Based on the idea from PR #6735 by maymuneth, reimplemented to use the
existing env-var-based authorization system rather than a nonexistent
_allowed_user_ids adapter attribute.
2026-04-09 14:07:32 -07:00
aaronagent
ab7b407224 fix: atomic Slack approval guard, safe JSON deserialization fallbacks
1. gateway/platforms/slack.py: Replace check-then-set TOCTOU race on
   _approval_resolved with atomic dict.pop(). Two concurrent button
   clicks could both pass the guard before either set it to True,
   causing double resolve_gateway_approval — which can resolve the
   WRONG queued approval when multiple are pending for the same session.

2. hermes_state.py: Add WARNING log and proper fallbacks when
   json.loads fails on tool_calls (→ []), reasoning_details (→ None),
   and codex_reasoning_items (→ None). Previously, failures were
   silently swallowed: tool_calls stayed as a raw string (iterating
   yields characters, not objects), and reasoning fields were simply
   missing from the dict.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:07:32 -07:00
Teknium
c6974fd108 fix: allow custom endpoint users to use main model for auxiliary tasks
Step 1 of _resolve_auto() explicitly excluded 'custom' providers,
forcing custom endpoint users through the fragile fallback chain
instead of using their known-working main model credentials.

This caused silent compression failures for users on local OpenAI-
compatible endpoints — the summary generation would fail, middle
turns would be silently dropped, and the agent would lose all
conversation context.

Remove 'custom' from the exclusion list so custom endpoint users
get the same main-model-first treatment as DeepSeek, Anthropic,
Gemini, and other direct providers.
2026-04-09 13:23:56 -07:00
Dylan Socolobsky
c6dba918b3 fix(tests): fix several failing/flaky tests on main (#6777)
* fix(tests): mock is_safe_url in tests that use example.com

Tests using example.com URLs were failing because is_safe_url does a real DNS lookup which fails in environments where example.com doesn't resolve, causing the request to be blocked before reaching the already-mocked HTTP client. This should fix around 17 failing tests.

These tests test logic, caching, etc. so mocking this method should not modify them in any way. TestMattermostSendUrlAsFile was already doing this so we follow the same pattern.

* fix(test): use case-insensitive lookup for model context length check

DEFAULT_CONTEXT_LENGTHS uses inconsistent casing (MiniMax keys are lowercase, Qwen keys are mixed-case) so the test was broken in some cases since it couldn't find the model.

* fix(test): patch is_linux in systemd gateway restart test

The test only patched is_macos to False but didn't patch is_linux to True. On macOS hosts, is_linux() returns False and the systemd restart code path is skipped entirely, making the assertion fail.

* fix(test): use non-blocklisted env var in docker forward_env tests

GITHUB_TOKEN is in api_key_env_vars and thus in _HERMES_PROVIDER_ENV_BLOCKLIST so the env var is silently dropped, we replace it with a non-blocked one like DATABASE_URL so the tests actually work.

* fix(test): fully isolate _has_any_provider_configured from host env

_has_any_provider_configured() checks all env vars from PROVIDER_REGISTRY (not just the 5 the tests were clearing) and also calls get_auth_status() which detects gh auth token for Copilot. On machines with any of these set, the function returns True before reaching the code path under test.

Clear all registry vars and mock get_auth_status so host credentials don't interfere.

* fix(test): correct path to hermes_base_env.py in tool parser tests

Path(__file__).parent.parent resolved to tests/, not the project root.
The file lives at environments/hermes_base_env.py so we need one more parent level.

* fix(test): accept optional HTML fields in Matrix send payload

_send_matrix sometimes adds format and formatted_body when the markdown library is installed. The test was doing an exact dict equality check which broke. Check required fields instead.

* fix(test): add config.yaml to codex vision requirements test

The test only wrote auth.json but not config.yaml, so _read_main_provider() returned empty and vision auto-detect never tried the codex provider. Add a config.yaml pointing at openai-codex so the fallback path actually resolves the client.

* fix(test): clear OPENROUTER_API_KEY in _isolate_hermes_home

run_agent.py calls load_hermes_dotenv() at import time, which injects API keys from ~/.hermes/.env into os.environ before any test fixture runs. This caused test_agent_loop_tool_calling to make real API calls instead of skipping, which ends up making some tests fail.

* fix(test): add get_rate_limit_state to agent mock in usage report tests

_show_usage now calls agent.get_rate_limit_state() for rate limit
  display. The SimpleNamespace mock was missing this method.

* fix(test): update expected Camofox config version from 12 to 13

* fix(test): mock _get_enabled_platforms in nous managed defaults test

Importing gateway.run leaks DISCORD_BOT_TOKEN into os.environ, which makes _get_enabled_platforms() return ["cli", "discord"] instead of just ["cli"]. tools_command loops per platform, so apply_nous_managed_defaults
  runs twice: the first call sets config values, the second sees them as
  already configured and returns an empty set, causing the assertion to
  fail.
2026-04-09 13:17:06 -07:00
Teknium
3eade90b39 fix: OpenClaw migration now shows dry-run preview before executing (#6769)
The setup wizard's OpenClaw migration previously ran immediately with
aggressive defaults (overwrite=True, preset=full) after a single
'Would you like to import?' prompt. This caused several problems:

- Config values with different semantics (e.g. tool_call_execution:
  'auto' in OpenClaw vs 'off' for Hermes yolo mode) were imported
  without translation
- Gateway tokens were hijacked from OpenClaw without warning, taking
  over Telegram/Slack/Discord channels
- Instruction files (.md) containing OpenClaw-specific setup/restart
  procedures were copied, causing Hermes restart failures

Now the migration:
1. Asks 'Would you like to see what can be imported?' (softer framing)
2. Runs a dry-run preview showing everything that would be imported
3. Displays categorized warnings for high-impact items (gateway
   takeover, config value differences, instruction files)
4. Asks for explicit confirmation with default=No
5. Executes with overwrite=False (preserves existing Hermes config)

Also extracts _load_openclaw_migration_module() for reuse and adds
_print_migration_preview() with keyword-based warning detection.

Tests updated for two-phase behavior + new test for decline-after-preview.
2026-04-09 12:15:06 -07:00
KUSH42
34d06a9802 fix(compaction): don't halve context_length on output-cap-too-large errors
When the API returns "max_tokens too large given prompt" (input tokens
are within the context window, but input + requested output > window),
the old code incorrectly routed through the same handler as "prompt too
long" errors, calling get_next_probe_tier() and permanently halving
context_length. This made things worse: the window was fine, only the
requested output size needed trimming for that one call.

Two distinct error classes now handled separately:

  Prompt too long  — input itself exceeds context window.
    Fix: compress history + halve context_length (existing behaviour,
    unchanged).

  Output cap too large — input OK, but input + max_tokens > window.
    Fix: parse available_tokens from the error message, set a one-shot
    _ephemeral_max_output_tokens override for the retry, and leave
    context_length completely untouched.

Changes:
- agent/model_metadata.py: add parse_available_output_tokens_from_error()
  that detects Anthropic's "available_tokens: N" error format and returns
  the available output budget, or None for all other error types.
- run_agent.py: call the new parser first in the is_context_length_error
  block; if it fires, set _ephemeral_max_output_tokens (with a 64-token
  safety margin) and break to retry without touching context_length.
  _build_api_kwargs consumes the ephemeral value exactly once then clears
  it so subsequent calls use self.max_tokens normally.
- agent/anthropic_adapter.py: expand build_anthropic_kwargs docstring to
  clearly document the max_tokens (output cap) vs context_length (total
  window) distinction, which is a persistent source of confusion due to
  the OpenAI-inherited "max_tokens" name.
- cli-config.yaml.example: add inline comments explaining both keys side
  by side where users are most likely to look.
- website/docs/integrations/providers.md: add a callout box at the top
  of "Context Length Detection" and clarify the troubleshooting entry.
- tests/test_ctx_halving_fix.py: 24 tests across four classes covering
  the parser, build_anthropic_kwargs clamping, ephemeral one-shot
  consumption, and the invariant that context_length is never mutated
  on output-cap errors.
2026-04-09 11:27:41 -07:00
Teknium
2772d99085 fix: remove /prompt slash command — footgun via prefix expansion (#6752)
/pr <anything> silently resolved to /prompt via the shortest-match
tiebreaker in prefix expansion, permanently overwriting the system
prompt and persisting to config. The command's functionality (setting
agent.system_prompt) is available via config.yaml and /personality
covers the common use case.

Removes: CommandDef, dispatch branch, _handle_prompt_command handler,
docs references, and updates subcommand extraction test.
2026-04-09 11:27:27 -07:00
Teknium
ee16416c7b fix(cli): prefer auth.py env vars over models.dev in provider detection (#6755)
list_authenticated_providers() was using env var names from the external
models.dev registry to detect credentials. This registry has incorrect
mappings for 5 providers: minimax-cn, zai, opencode-zen, opencode-go,
and kilocode — causing them to not appear in /model even when the
correct API key is set.

Now checks PROVIDER_REGISTRY from auth.py first (our source of truth),
falling back to models.dev only for providers not in our registry.

Fixes #6620. Based on devorun's investigation in PR #6625.
2026-04-09 11:13:11 -07:00
Teknium
3007174a61 fix: prevent 400 format errors from triggering compression loop on Codex Responses API (#6751)
The error classifier's generic-400 heuristic only extracted err_body_msg from
the nested body structure (body['error']['message']), missing the flat body
format used by OpenAI's Responses API (body['message']). This caused
descriptive 400 errors like 'Invalid input[index].name: string does not match
pattern' to appear generic when the session was large, misclassifying them as
context overflow and triggering an infinite compression loop.

Added flat-body fallback in _classify_400() consistent with the parent
classify_api_error() function's existing handling at line 297-298.
2026-04-09 11:11:34 -07:00
Yang Zhi
2f0a83dd12 fix(cli): update TUI status bar model name on provider fallback
The status bar reads self.model from the CLI class, which is set once
at init and never updated when _try_activate_fallback() switches to a
backup provider/model in run_agent.py. This causes the TUI to display
the original model name while context_length_max changes, creating a
confusing mismatch.

Read the model name from agent.model (live, updated by fallback) with
self.model as fallback before the agent is created. Remove the
redundant getattr(self, 'agent') call that was already done above.
2026-04-09 11:11:25 -07:00
Yang Zhi
110cdd573a fix(auxiliary_client): inject KimiCLI User-Agent for custom endpoint sync clients
When  is explicitly set to ,
the custom-endpoint path in  creates a plain
client without provider-specific headers. This means sync vision calls (e.g.
) use the generic  User-Agent and get rejected by
Kimi's coding endpoint with a 403:

    'Kimi For Coding is currently only available for Coding Agents such as Kimi CLI...'

The async converter  already injects , and the
auto-detected API-key provider path also injects it, but the explicit custom
endpoint shortcut was missing it entirely.

This patch adds the same  injection to the custom endpoint
branch, and updates all existing Kimi header sites to  for
consistency.

Fixes <issue number to be filled in>
2026-04-09 11:11:25 -07:00
Yang Zhi
4d1b988070 fix(credential_pool): use _resolve_kimi_base_url when seeding kimi-coding pool
The credential pool seeder (_seed_from_env) hardcoded the base URL
for API-key providers without running provider-specific auto-detection.
For kimi-coding, this caused sk-kimi- prefixed keys to be seeded with
the legacy api.moonshot.ai/v1 endpoint instead of api.kimi.com/coding/v1,
resulting in HTTP 401 on the first request.

Import and call _resolve_kimi_base_url for kimi-coding so the pool
uses the correct endpoint based on the key prefix, matching the
runtime credential resolver behavior.

Also fix a comment: sk-kimi- keys are issued by kimi.com/code,
not platform.kimi.ai.

Fixes #5561
2026-04-09 11:11:25 -07:00
Yang Zhi
019c11d07e fix(fallback): preserve provider-specific headers when activating fallback
When _try_activate_fallback() swaps to a new provider (e.g.
kimi-coding), resolve_provider_client() correctly injects
provider-specific default_headers (like KimiCLI User-Agent) into the
returned OpenAI client. However, _client_kwargs was saved with only
api_key and base_url, dropping those headers.

Every subsequent API call rebuilds the client from _client_kwargs via
_create_request_openai_client(), producing a bare OpenAI client without
the required headers. Kimi Coding rejects this with 403; Copilot would
lose its auth headers similarly.

This patch reads _custom_headers from the fallback client (where the
OpenAI SDK stores the default_headers kwarg) and includes them in
_client_kwargs so any client rebuild preserves provider-specific headers.

Fixes #6075
2026-04-09 11:11:25 -07:00
MustafaKara7
fce23e8024 fix(docker): #6197 enable unbuffered stdout for live logs 2026-04-09 10:59:31 -07:00
Teknium
1ec1f6a68a fix: model fallback — stale model on Nous login + connection error fallback (#6554)
Two bugs in the model fallback system:

1. Nous login leaves stale model in config (provider=nous, model=opus
   from previous OpenRouter setup). Fixed by deferring the config.yaml
   provider write until AFTER model selection completes, and passing the
   selected model atomically via _update_config_for_provider's
   default_model parameter. Previously, _update_config_for_provider was
   called before model selection — if selection failed (free tier, no
   models, exception), config stayed as nous+opus permanently.

2. Codex/stale providers in auxiliary fallback can't connect but block
   the auto-detection chain. Added _is_connection_error() detection
   (APIConnectionError, APITimeoutError, DNS failures, connection
   refused) alongside the existing _is_payment_error() check in
   call_llm(). When a provider endpoint is unreachable, the system now
   falls back to the next available provider instead of crashing.
2026-04-09 10:38:53 -07:00
ethernet
637ad443bf nix: add tirith to runtime deps (#6721) 2026-04-09 22:28:00 +05:30
Devorun
a8b85bb887 fix(nix): make setupSecrets activation script optional (#6227) (#6261) 2026-04-09 22:09:20 +05:30
Sergei Korolev
d9753720f3 fix(nix): switch nixpkgs input from nixos-24.11 to nixos-unstable (#5520)
* fix(nix): switch nixpkgs input from nixos-24.11 to nixos-unstable

nixos-24.11 reached EOL on 2025-06-30. For a dev tool, tracking a
frozen release branch causes dependency versions to go stale.
nixos-unstable provides rolling updates and is the conventional
choice for development packages.

* docs(website): update nix flake example

---------

Co-authored-by: sk <sk@mercury>
2026-04-09 21:30:38 +05:30
Dilek
dbc11abcb6 fix(ci): pin floating GitHub Actions tags and ascii-guard to explicit versions (#3982)
* fix(ci): pin floating GitHub Actions tags and ascii-guard to explicit versions

Actions pinned to @main pull whatever is at that ref at execution time,
so a compromised upstream org could execute arbitrary code in CI.

- Pin DeterminateSystems/nix-installer-action to commit SHA (v22)
- Pin DeterminateSystems/magic-nix-cache-action to commit SHA (v13)
- Pin ascii-guard to 2.3.0 in docs-site-checks workflow

SHA comments include the version tag for human readability; Renovate or
Dependabot can keep these updated automatically.

* Add skill metadata extraction step in workflow

Add step to extract skill metadata for dashboard in CI workflow.

---------

Co-authored-by: Siddharth Balyan <52913345+alt-glitch@users.noreply.github.com>
2026-04-09 21:27:20 +05:30
Teknium
268ee6bdce fix: add turn-exit diagnostic logging to agent loop (#6549)
Every turn now logs WHY the agent loop ended to agent.log with a
structured INFO line capturing: exit reason, model, api_calls/max,
budget usage, tool turn count, last message role, response length,
and session ID.

When the last message is a tool result and the turn was NOT
interrupted, emits WARNING level (visible in errors.log) — this is
the 'just stops' scenario users report where a tool call completes
but no continuation or final response follows.

10 tracked exit reasons: text_response, interrupted_by_user,
interrupted_during_api_call, budget_exhausted, max_iterations_reached,
all_retries_exhausted_no_response, fallback_prior_turn_content,
empty_response_exhausted, error_near_max_iterations, unknown.
2026-04-09 04:15:22 -07:00
Teknium
173289b64f docs: add hermes dump and hermes logs to CLI commands reference (#6552)
Documents both debugging commands with full option tables,
examples, and usage guidance. Adds both to the top-level
commands table and as detailed sections with subsections for
log files, filtering behavior, and log rotation.
2026-04-09 04:11:03 -07:00
Teknium
1a3ae6ac6e feat: structured API error classification for smart failover (#6514)
Add agent/error_classifier.py with a priority-ordered classification
pipeline that replaces scattered inline string-matching in the retry
loop with structured error taxonomy and recovery hints.

FailoverReason enum (14 categories): auth, auth_permanent, billing,
rate_limit, overloaded, server_error, timeout, context_overflow,
payload_too_large, model_not_found, format_error, thinking_signature,
long_context_tier, unknown.

ClassifiedError dataclass carries reason + recovery action hints
(retryable, should_compress, should_rotate_credential, should_fallback).

Key improvements over inline matching:
- 402 disambiguation: 'insufficient credits' = billing (immediate rotate),
  'usage limit, try again' = rate_limit (backoff first)
- OpenRouter 403 'key limit exceeded' correctly classified as billing
- Error cause chain walking (walks __cause__/__context__ up to 5 levels)
- Body message included in pattern matching (SDK str() misses it)
- Server disconnect + large session check ordered before generic transport
  catch so RemoteProtocolError triggers compression when appropriate
- Chinese error message support for context overflow

run_agent.py: replaced 6 inline detection blocks with classifier calls,
net -55 lines. All recovery actions (pool rotation, fallback activation,
compression, transport recovery) unchanged.

65 new unit tests + 10 E2E tests + live tests with real SDK error objects.
Inspired by OpenClaw's failover error classification system.
2026-04-09 04:10:11 -07:00
Teknium
78e6b06518 feat: add 'hermes dump' command for copy-pasteable setup summary (#6550)
Adds a new CLI command that outputs a compact, plain-text dump of the
user's Hermes setup — version, OS, model/provider, API key presence,
toolsets, gateway status, platforms, cron jobs, skills, and any
non-default config overrides.

Designed for support context: no ANSI colors, ready to paste into
Discord/GitHub/Telegram. Secrets shown as 'set/not set' by default;
--show-keys reveals redacted prefixes (first/last 4 chars).

Files:
- hermes_cli/dump.py (new) — run_dump() implementation
- hermes_cli/main.py — parser + cmd_dump wiring
- hermes_cli/profiles.py — shell completions + subcommand set
2026-04-09 04:00:41 -07:00
Teknium
b650957b40 docs(bluebubbles): fix pairing instructions to use existing approve flow (#6548)
The docs incorrectly referenced 'hermes pairing generate bluebubbles'
which doesn't exist. The existing reactive pairing flow already handles
this — when an unknown user messages the bot, it sends them a code
automatically, and the owner approves with 'hermes pairing approve'.
2026-04-09 03:57:11 -07:00
Teknium
ad06bfccf0 fix: remove dead LLM_MODEL env var — add migration to clear stale .env entries (#6543)
The old setup wizard (pre-March 2026) wrote LLM_MODEL to ~/.hermes/.env
across 12 provider flows. Commit 9302690e removed the writes but never
cleaned up existing .env files, leaving a dead variable that:
- Nothing in the codebase reads (zero os.getenv calls)
- The docs incorrectly claimed the gateway still used as fallback
- Caused user confusion when debugging model resolution issues

Changes:
- config.py: Bump _config_version 12 → 13, add migration to clear
  LLM_MODEL and OPENAI_MODEL from .env (both dead since March 2026)
- environment-variables.md: Remove LLM_MODEL row, fix HERMES_MODEL
  description to stop referencing it
- providers.md: Update deprecation notice from 'deprecated' to 'removed'
2026-04-09 03:56:40 -07:00
Teknium
8dfc96dbbb feat: capture provider rate limit headers and show in /usage (#6541)
Parse x-ratelimit-* headers from inference API responses (Nous Portal,
OpenRouter, OpenAI-compatible) and display them in the /usage command.

- New agent/rate_limit_tracker.py: parse 12 rate limit headers (RPM/RPH/
  TPM/TPH limits, remaining, reset timers), format as progress bars (CLI)
  or compact one-liner (gateway)
- Hook into streaming path in run_agent.py: stream.response.headers is
  available on the OpenAI SDK Stream object before chunks are consumed
- CLI /usage: appends rate limit section with progress bars + warnings
  when any bucket exceeds 80%
- Gateway /usage: appends compact rate limit summary
- 24 unit tests covering parsing, formatting, edge cases

Headers captured per response:
  x-ratelimit-{limit,remaining,reset}-{requests,tokens}{,-1h}

Example CLI display:
  Nous Rate Limits (captured just now):
    Requests/min [░░░░░░░░░░░░░░░░░░░░]  0.1%  1/800 used  (799 left, resets in 59s)
    Tokens/hr    [░░░░░░░░░░░░░░░░░░░░]  0.0%  49/336.0M   (336.0M left, resets in 52m)
2026-04-09 03:43:14 -07:00
konsisumer
3c8ec7037c fix(agent): catch PermissionError in subdirectory hint discovery
Wrap is_dir() in _is_valid_subdir() and is_file() in
_load_hints_for_directory() with OSError handlers so that
inaccessible directories (e.g. /root from a non-root Daytona
host user) are silently skipped instead of crashing the agent.

The existing PermissionError PRs for prompt_builder.py (#6247,
#6321, #6355) do not cover subdirectory_hints.py, which was
identified as a separate crash path in the #6214 comments.

Ref: #6214
2026-04-09 03:10:30 -07:00
Kira
161c2c4da4 fix(skills): archive OpenClaw cron store without config 2026-04-09 03:06:11 -07:00
Lumen Radley
e22416dd9b fix: handle empty sudo password and false prompts 2026-04-09 02:50:07 -07:00
Teknium
a94099908a fix(state): orphan children instead of cascade-deleting in prune/delete (#6513)
prune_sessions and delete_session only handled direct children when
satisfying the parent_session_id FK constraint. Multi-level chains
(A -> B -> C) caused IntegrityError because deleting B while C still
referenced it was blocked by the FK.

Fix: NULL out parent_session_id for any session whose parent is about
to be deleted. This orphans children instead of cascade-deleting them,
which also respects the prune retention window — newer child sessions
are no longer deleted just because an ancestor is old.

Reported by Aaryan2304 in PR #6463.
2026-04-09 02:41:56 -07:00
cokemine
851857e413 fix(models): correct probed_url selection logic
Updated the logic for determining the probed_url in the probe_api_models function to use the first tried URL instead of the last. This change ensures that the most relevant URL is returned when probing for models. Additionally, improved the output message in the _model_flow_custom function to provide clearer guidance based on the suggested_base_url.
2026-04-09 02:38:09 -07:00
Teknium
b408379e9d fix: reduce credential exhaustion TTL from 24 hours to 1 hour (#6504)
The 24-hour default cooldown for 402-exhausted credentials was far too
aggressive — if a user tops up credits or the 402 was caused by an
oversized max_tokens request rather than true billing exhaustion, they
shouldn't have to wait a full day. Reduce to 1 hour (matching the
existing 429 TTL).

Inspired by PR #6493 (michalkomar).
2026-04-09 02:37:23 -07:00
Kira
e1b0b135cb fix(discord): accept .log attachments and raise document size limit 2026-04-09 02:26:33 -07:00
Teknium
1eabbe905e fix: retry 3 times when model returns truly empty response (#6488)
When a model returns no content, no structured reasoning, and no tool
calls (common with open models), the agent now silently retries up to
3 times before falling through to (empty).

Silent retry (no synthetic messages) keeps the conversation history
clean, preserves prompt caching, and respects the no-synthetic-user-
injection invariant.  Most empty responses from open models are
transient (provider hiccups, rate limits, sampling flukes) so a
simple retry is sufficient.

This fills the last gap in the empty-response recovery chain:
1. _last_content_with_tools fallback (prior tool turn had content)
2. Thinking-only prefill continuation (#5931 — structured reasoning)
3. Empty response silent retry (NEW — truly empty, no reasoning)
4. (empty) terminal (last resort after all retries exhausted)

Inline <think> blocks are excluded — the model chose to reason, it
just produced no visible text.  That differs from truly empty.

Tests:
- Updated test_truly_empty to expect 4 API calls (1 + 3 retries)
- Added test_truly_empty_response_succeeds_on_nudge
2026-04-09 02:06:12 -07:00
Teknium
b962801f6a fix(bluebubbles): add setup wizard integration and OPTIONAL_ENV_VARS (#6494)
The BlueBubbles adapter was merged but missing setup wizard support:
- Add _setup_bluebubbles() guided setup (server URL, password, allowlist,
  home channel, webhook port)
- Add to _GATEWAY_PLATFORMS registry so it appears in 'hermes setup gateway'
- Add to any_messaging check and home channel missing warning
- Add to gateway status display in 'hermes setup'
- Add BLUEBUBBLES_SERVER_URL, BLUEBUBBLES_PASSWORD, BLUEBUBBLES_ALLOWED_USERS
  to OPTIONAL_ENV_VARS with descriptions and categories

Previously the only way to configure BlueBubbles was manually editing .env.
2026-04-09 02:05:41 -07:00
Cherif Yaya
5cf4fac2aa fix: restore codex fallback auth-store lookup 2026-04-09 01:56:10 -07:00
Hunter B
894e8c8a8f fix: resolve opencode.ai context window to 1M and clean up display formatting
Two issues resolved:

1. Add opencode.ai to _URL_TO_PROVIDER mapping so base_url routes through
   models.dev lookup (which has mimo-v2-pro at 1M context) instead of
   falling back to probing /models (404) and defaulting to 128K.

2. Fix _format_context_length to round cleanly: 1048576 → '1M' instead
   of '1.048576M'. Applies same rounding logic to K values.
2026-04-09 01:43:22 -07:00
Teknium
18140199c3 fix(ci): build and push multi-arch Docker image (amd64 + arm64) (#6124)
Add QEMU cross-compilation and multi-arch manifest support so Apple
Silicon (M1/M2/M3) and other ARM-based systems get native images.

- Add docker/setup-qemu-action for arm64 emulation on amd64 runners
- Smoke test stays amd64-only (load:true can't export multi-arch)
- Both push steps (main + release) now build linux/amd64,linux/arm64
- Bump timeout 30->60min for QEMU cross-compilation overhead
- Add permissions: contents: read (least-privilege hardening)

Salvaged from PR #3998 by Mibayy. Also addresses #5005 and #3913.

Co-authored-by: Mibayy <Mibayy@users.noreply.github.com>
2026-04-09 00:29:45 -07:00
Teknium
7120d6cdd6 fix(bluebubbles): add missing integration points and documentation (#6460)
- hermes_cli/skills_config.py: add platform label for per-platform skill config
- gateway/session.py: add to PII-safe platforms (no mention system)
- website/docs/user-guide/messaging/bluebubbles.md: full setup guide
- website/sidebars.ts: sidebar navigation entry
- 10 docs pages: add BlueBubbles to all platform enumerations
  (env vars, toolsets, cron delivery, gateway internals, etc.)
2026-04-09 00:19:05 -07:00
Teknium
d40264d53b test: add coverage for token-budget tail protection
Tests for the new behavior paths:
- Large tool outputs no longer block compaction (motivating scenario)
- Hard minimum of 3 tail messages always protected
- 1.5x soft ceiling for oversized messages
- Small conversations still compress (min 8 messages)
- Token-budget prune path in _prune_old_tool_results
- Fallback to message-count when no token budget
2026-04-08 23:54:23 -07:00
BongSuCHOI
c506126123 fix(tests): update context_compressor tests for min_tail=3
PR #6240 changed tail protection from protect_last_n to min(3, ...)
which increased the minimum compressible message count and shifted
tail boundaries. Three tests broke:

- test_summary_role_avoids_consecutive_user_messages: 6→8 msgs
- test_double_collision_user_head_assistant_tail: 7→8 msgs
- test_no_collision_scenarios_still_work: 6→8 msgs

All tests now exceed the new min_for_compress threshold (6) and
maintain proper role alternation in both head and tail sections.
2026-04-08 23:54:23 -07:00
BongSuCHOI
d12f8db0b8 fix(compaction): token-budget primary tail protection
Tail protection was effectively message-count based despite having a
token budget, because protect_last_n=20 acted as a hard floor.  A single
50K-token tool output would cause all 20 recent messages to be
preserved regardless of budget, leaving little room for summarization.

Changes:
- _find_tail_cut_by_tokens: min_tail reduced from protect_last_n (20)
  to 3; token budget is now the primary criterion
- Soft ceiling at 1.5x budget to avoid cutting mid-oversized-message
- _prune_old_tool_results: accepts optional protect_tail_tokens so
  pruning also respects the token budget instead of a fixed count
- compress() minimum message check relaxed from protect_first_n +
  protect_last_n + 1 to protect_first_n + 3 + 1
- Tool group alignment (no splitting tool_call/result) preserved
2026-04-08 23:54:23 -07:00
Nicolò Boschi
25757d631b feat(hindsight): feature parity, setup wizard, and config improvements
Port missing features from the hindsight-hermes external integration
package into the native plugin. Only touches plugin files — no core
changes.

Features:
- Tags on retain/recall (tags, recall_tags, recall_tags_match)
- Recall config (recall_max_tokens, recall_max_input_chars, recall_types,
  recall_prompt_preamble)
- Retain controls (retain_every_n_turns, auto_retain, auto_recall,
  retain_async via aretain_batch, retain_context)
- Bank config via Banks API (bank_mission, bank_retain_mission)
- Structured JSON retain with per-message timestamps
- Full session accumulation with document_id for dedup
- Custom post_setup() wizard with curses picker
- Mode-aware dep install (hindsight-client for cloud, hindsight-all for local)
- local_external mode and openai_compatible LLM provider
- OpenRouter support with auto base URL
- Auto-upgrade of hindsight-client to >=0.4.22 on session start
- Comprehensive debug logging across all operations
- 46 unit tests
- Updated README and website docs
2026-04-08 23:54:15 -07:00
Teknium
d97f6cec7f feat(gateway): add BlueBubbles iMessage platform adapter (#6437)
Adds Apple iMessage as a gateway platform via BlueBubbles macOS server.

Architecture:
- Webhook-based inbound (event-driven, no polling/dedup needed)
- Email/phone → chat GUID resolution for user-friendly addressing
- Private API safety (checks helper_connected before tapback/typing)
- Inbound attachment downloading (images, audio, documents cached locally)
- Markdown stripping for clean iMessage delivery
- Smart progress suppression for platforms without message editing

Based on PR #5869 by @benjaminsehl (webhook architecture, GUID resolution,
Private API safety, progress suppression) with inbound attachment downloading
from PR #4588 by @1960697431 (attachment cache routing).

Integration points: Platform enum, env config, adapter factory, auth maps,
cron delivery, send_message routing, channel directory, platform hints,
toolset definition, setup wizard, status display.

27 tests covering config, adapter, webhook parsing, GUID resolution,
attachment download routing, toolset consistency, and prompt hints.
2026-04-08 23:54:03 -07:00
Teknium
241bd4fc7e fix: add size cap to assistant thread metadata cache
Prevents unbounded memory growth in _assistant_threads dict.
Evicts oldest entries when exceeding _ASSISTANT_THREADS_MAX (5000),
matching the pattern used by _mentioned_threads and _seen_messages.
2026-04-08 23:53:50 -07:00
helix4u
30a0fcaec8 fix(slack): handle assistant thread lifecycle events 2026-04-08 23:53:50 -07:00
Teknium
5449c01d26 fix: clean env vars in pairing regression test
The test_non_internal_event_without_user_triggers_pairing test relied on
no Discord auth env vars being set, but gateway/run.py loads dotenv at
module level. In environments with DISCORD_ALLOW_ALL_USERS=True in .env,
the auth check passed instead of triggering the pairing flow.

Clear DISCORD_ALLOW_ALL_USERS, DISCORD_ALLOWED_USERS, GATEWAY_ALLOW_ALL_USERS,
and GATEWAY_ALLOWED_USERS via monkeypatch to ensure test isolation.
2026-04-08 23:01:04 -07:00
xingkongliang
1d8d4f28ae fix(gateway): prevent background process notifications from triggering false pairing requests
When a background process with notify_on_complete=True finishes, the
gateway injects a synthetic MessageEvent to notify the session. This
event was constructed without user_id, causing _is_user_authorized()
to reject it and — for DM-origin sessions — trigger the pairing flow,
sending "Hi~ I don't recognize you yet!" with a pairing code to the
chat owner.

Add an `internal` flag to MessageEvent that bypasses authorization
checks for system-generated synthetic events. Only the process watcher
sets this flag; no external/adapter code path can produce it.

Includes 4 regression tests covering the fix and the normal pairing path.
2026-04-08 23:01:04 -07:00
helix4u
e94008c404 fix(terminal): guard invalid command values 2026-04-08 21:37:51 -07:00
angelos
e7d3e9d767 fix(terminal): persistent sandbox envs survive between turns
`_cleanup_task_resources` was unconditionally calling `cleanup_vm()` at
the end of every `run_conversation` (i.e. every user turn), tearing down
the docker/daytona/modal sandbox container regardless of its
`persistent_filesystem` setting. This contradicted the documented intent
of `terminal.lifetime_seconds` (idle reaper) and `container_persistent`,
and caused per-turn loss of `/workspace`, `~/.config`, agent CLI auth
state, and any other content living inside the sandbox.

The unconditional teardown was introduced in fbd3a2fd ("prevent leakage
of morph instances between tasks", 2025-11-04) to plug a Morph backend
leak, two days after `lifetime_seconds` shipped in faecbddd. It was
later refactored into `_cleanup_task_resources` in 70dd3a16 without
changing semantics. Code and docs have disagreed since.

Fix: introduce `terminal_tool.is_persistent_env(task_id)` and skip the
per-turn `cleanup_vm` when the active env is persistent. The idle reaper
(`_cleanup_inactive_envs`) still tears persistent envs down once
`terminal.lifetime_seconds` is exceeded. Non-persistent backends (Morph)
are unchanged — still torn down per turn, preserving the original
leak-prevention intent.
2026-04-08 21:31:57 -07:00
Teknium
54db7cbbe1 fix(agent): tiered context pressure warnings + gateway dedup (#6411)
Combines the approaches from PR #6309 (duan78) and PR #5963 (KUSH42):

Tiered warnings (from #5963):
- Replaces boolean _context_pressure_warned with float _context_pressure_warned_at
- Fires at 85% (orange) and re-fires at 95% (red/critical)
- Adds 'compacting context...' status message before compression

Gateway dedup (from #6309):
- Class-level dict _context_pressure_last_warned survives across AIAgent
  instances (gateway creates a new instance per message)
- 5-minute cooldown per session prevents warning spam
- Higher-tier warnings bypass the cooldown (85% → 95% always fires)
- Compression reset clears the dedup entry for the session
- Stale entries evicted (older than 2x cooldown) to prevent memory leak

Does NOT inject into messages — purely user-facing via _safe_print (CLI)
and status_callback (gateway). Zero prompt cache impact.

Fixes #6309. Fixes #5963.
2026-04-08 21:31:44 -07:00
Hermes Agent
ffeaf6ffae feat(discord): inherit forum channel topic in thread sessions
ORIGINAL INCIDENT:
Discord forum descriptions (the topic field on ForumChannel) were invisible
to the agent. When a user set project instructions in a forum's description
(e.g. tool-evaluations), threads created in that forum had no Channel Topic
in their session context. Discovered while evaluating per-forum auto-context
injection for web-tap-terminal development threads.

ISSUE IN THE CODE:
In gateway/platforms/discord.py, all three session entry points
(_handle_message, _build_slash_event, _dispatch_thread_session) read
chat_topic via getattr(channel, 'topic', None). Discord Thread objects
don't carry a topic — only the parent ForumChannel does. So chat_topic
was always None for forum threads, and the Channel Topic line was never
injected into build_session_context_prompt output. The infrastructure to
handle this was already in place — _is_forum_parent() detects forum
channels, _format_thread_chat_name() traverses to the parent, and
build_session_context_prompt() renders Channel Topic when present. The
forum parent was being identified; its topic just wasn't being read.

HOW THIS COMMIT FIXES IT:
Adds _get_effective_topic(channel, is_thread) helper that reads
channel.topic first, then falls back to the parent forum's topic when
the channel is a thread inside a forum. All three session entry points
now call this helper instead of inlining getattr(channel, 'topic', None).
Existing tests pass unchanged.

Co-authored-by: dhabibi <9087935+dhabibi@users.noreply.github.com>
2026-04-08 21:29:04 -07:00
Teknium
989d4ea43d fix: set compression_count on mock to avoid TypeError in test
The new degradation warning reads compression_count as an int,
but the existing test's MagicMock returns a MagicMock object
for that attribute, causing '>=' comparison to fail.
2026-04-08 20:54:23 -07:00
SHL0MS
8567031433 fix: improve context compression quality — named constants, tool tracking, degradation warning
Three targeted improvements to the compression system:

1. Replace hardcoded truncation limits with named class constants
   (_CONTENT_MAX=6000, _CONTENT_HEAD=4000, _CONTENT_TAIL=1500,
   _TOOL_ARGS_MAX=1500, _TOOL_ARGS_HEAD=1200). Previous limits
   (3000/500) heavily truncated the summarizer's input — a 200-line
   edit got cut to 3000 chars before the summarizer ever saw it.

2. Add '## Tools & Patterns' section to both compression prompt
   templates (first-pass and iterative). Preserves working tool
   invocations, preferred flags, and tool-specific discoveries
   across compaction boundaries.

3. Warn users on 2nd+ compression: 'Session compressed N times —
   accuracy may degrade. Consider /new to start fresh.'

Ref #499
2026-04-08 20:54:23 -07:00
Teknium
af4abd2f22 fix: correct unbound exception variable and remaining-time math in warning
- Bind exception in warning send handler (was using stale _ne from outer scope)
- Calculate remaining time until timeout correctly: (timeout - warning) // 60
  instead of warning // 60 (which equals elapsed time, not remaining)
2026-04-08 20:01:06 -07:00
Helmi
092061711e fix(gateway): add staged inactivity warning before timeout escalation
Introduce gateway_timeout_warning (default 900s) as a pre-timeout alert
layer.  When inactivity reaches the warning threshold, a single
notification is sent to the user offering to wait or reset.  If
inactivity continues to the gateway_timeout (default 1800s), the full
timeout fires as before.

This gives users a chance to intervene before work is lost on slow
API providers without disabling the safety timeout entirely.

Config: agent.gateway_timeout_warning in config.yaml, or
HERMES_AGENT_TIMEOUT_WARNING env var (0 = disable warning).
2026-04-08 20:01:06 -07:00
Teknium
980fadfea9 fix(models): preserve OpenRouter variant tags (:free, :extended, :fast) during model switch (#6383)
Step c in switch_model() blindly converted the first colon to a slash for
aggregator providers, even when the model name already contained a slash
(vendor/model format). This mangled variant tags like :free into /free,
causing 400 Bad Request from the API.

Fix: skip the colon→slash conversion when the model already has a slash,
since the colon is a variant tag, not a vendor separator. The module
docstring already documented this intent (line 17-18) but the
implementation didn't enforce it.

Reported via Discord. Related to PR #6088 (which identified the same bug
but placed the fix in model_normalize.py instead of model_switch.py where
the actual mangling occurs).
2026-04-08 19:58:16 -07:00
Teknium
ae4a884e8d fix(agent): disable stale stream timeout for local providers (#6368)
Local inference providers (Ollama, oMLX, llama-cpp) can take 300+ seconds
for prefill on large contexts. The 180s stale stream detector was killing
these connections while the provider was still processing.

Uses the existing is_local_endpoint() (proper URL parsing with RFC-1918,
localhost, WSL detection) instead of ad-hoc substring matching. The stale
timeout is only disabled when the user hasn't explicitly set
HERMES_STREAM_STALE_TIMEOUT — explicit user config is always honored.

Fixes #5889
2026-04-08 19:53:39 -07:00
Teknium
6e3f7f3610 docs: add tool_progress_overrides to configuration reference (#6364)
Documents the per-platform tool_progress_overrides config key added in
PR #6348. Shows example YAML with Signal set to 'off' while Telegram
stays on 'verbose'. Lists all valid platform keys.
2026-04-08 19:04:21 -07:00
konsisumer
42e366f27b fix(agent): respect config timeout for flush_memories instead of hardcoded 30s
The _call_llm() and direct OpenAI fallback paths in flush_memories() both
hardcoded timeout=30.0, ignoring the user-configurable value at
auxiliary.flush_memories.timeout in config.yaml.

Remove the explicit timeout from the auxiliary _call_llm() call so that
_get_task_timeout('flush_memories') reads from config. For the direct
OpenAI fallback, import and use _get_task_timeout() instead of the
hardcoded value.

Add two regression tests verifying both code paths respect the config.

Fixes #6154
2026-04-08 18:55:33 -07:00
Teknium
3baafea380 fix(tools): skip camofox auto-cleanup when managed persistence is enabled (#6233)
When managed_persistence is enabled, cleanup_browser() was calling
camofox_close() which destroys the server-side browser context via
DELETE /sessions/{userId}, killing login sessions across cron runs.

Add camofox_soft_cleanup() — a public wrapper that drops only the
in-memory session entry when managed persistence is on, returning True.
When persistence is off it returns False so the caller falls back to
the full camofox_close().  The inactivity reaper still handles idle
resource cleanup.

Also surface a logger.warning() when _managed_persistence_enabled()
fails to load config, replacing a silent except-and-return-False.

Salvaged from #6182 by el-analista (Eduardo Perea Fernandez).
Added public API wrapper to avoid cross-module private imports,
and test coverage for both persistence paths.

Co-authored-by: Eduardo Perea Fernandez <el-analista@users.noreply.github.com>
2026-04-08 18:07:18 -07:00
Teknium
e26393ffc2 fix: Signal duplicate replies with streaming + per-platform tool_progress (#6348)
Fixes #4647 — Signal replies duplicated when gateway streaming is enabled.

Root cause: stream_consumer.py did not handle the case where send() returns
success=True but no message_id (Signal behavior). Every stream delta produced
a separate send() call (7+ messages instead of 2), plus the gateway sent
another full duplicate since already_sent was never set.

Changes:
- stream_consumer.py: Add elif branch for success-without-message_id — enters
  fallback mode (sets already_sent, disables editing, sends only continuation)
- signal.py send(): Extract timestamp from signal-cli RPC result as message_id
  so stream consumer follows normal edit→fallback path
- signal.py: Add public stop_typing() delegating to _stop_typing_indicator()
  so base adapter's _keep_typing finally block can clean up typing tasks
- gateway/run.py: Per-platform tool_progress_overrides (#6164) — lets users
  set e.g. signal: off while keeping telegram: all
- hermes_cli/config.py: Add tool_progress_overrides to DEFAULT_CONFIG

Refs: #4647, #6164
2026-04-08 17:39:45 -07:00
Teknium
e19252afc4 fix: update tests for unified spawn-per-call execution model
- Docker env tests: verify _build_init_env_args() instead of per-execute
  Popen flags (env forwarding is now init-time only)
- Docker: preserve explicit forward_env bypass of blocklist from main
- Daytona tests: adapt to SDK-native timeout, _ThreadedProcessHandle,
  base.py interrupt handling, HERMES_STDIN_ heredoc prefix
- Modal tests: fix _load_module to include _ThreadedProcessHandle stub,
  check ensurepip in _resolve_modal_image instead of __init__
- SSH tests: mock time.sleep on base module instead of removed ssh import
- Add missing BaseEnvironment attributes to __new__()-based test fixtures
2026-04-08 17:23:15 -07:00
alt-glitch
d684d7ee7e feat(environments): unified spawn-per-call execution layer
Replace dual execution model (PersistentShellMixin + per-backend oneshot)
with spawn-per-call + session snapshot for all backends except ManagedModal.

Core changes:
- Every command spawns a fresh bash process; session snapshot (env vars,
  functions, aliases) captured at init and re-sourced before each command
- CWD persists via file-based read (local) or in-band stdout markers (remote)
- ProcessHandle protocol + _ThreadedProcessHandle adapter for SDK backends
- cancel_fn wired for Modal (sandbox.terminate) and Daytona (sandbox.stop)
- Shared utilities extracted: _pipe_stdin, _popen_bash, _load_json_store,
  _save_json_store, _file_mtime_key, _SYNC_INTERVAL_SECONDS
- Rate-limited file sync unified in base _before_execute() with _sync_files() hook
- execute_oneshot() removed; all 11 call sites in code_execution_tool.py
  migrated to execute()
- Daytona timeout wrapper replaced with SDK-native timeout parameter
- persistent_shell.py deleted (291 lines)

Backend-specific:
- Local: process-group kill via os.killpg, file-based CWD read
- Docker: -e env flags only on init_session, not per-command
- SSH: shlex.quote transport, ControlMaster connection reuse
- Singularity: apptainer exec with instance://, no forced --pwd
- Modal: _AsyncWorker + _ThreadedProcessHandle, cancel_fn -> sandbox.terminate
- Daytona: SDK-level timeout (not shell wrapper), cancel_fn -> sandbox.stop
- ManagedModal: unchanged (gateway owns execution); docstring added explaining why
2026-04-08 17:23:15 -07:00
Teknium
7d26feb9a3 feat(discord): add DISCORD_REPLY_TO_MODE setting (#6333)
Add configurable reply-reference behavior for Discord, matching the
existing Telegram (TELEGRAM_REPLY_TO_MODE) and Mattermost
(MATTERMOST_REPLY_MODE) implementations.

Modes:
- 'off': never reply-reference the original message
- 'first': reply-reference on first chunk only (default, current behavior)
- 'all': reply-reference on every chunk

Set DISCORD_REPLY_TO_MODE=off in .env to disable reply-to messages.

Changes:
- gateway/config.py: parse DISCORD_REPLY_TO_MODE env var
- gateway/platforms/discord.py: read reply_to_mode from config, respect
  it in send() — skip fetch_message entirely when 'off'
- hermes_cli/config.py: add to OPTIONAL_ENV_VARS for hermes setup
- 23 tests covering config, send behavior, env var override
- docs: discord.md env var table + environment-variables.md reference

Closes community request from Stuart on Discord.
2026-04-08 17:08:40 -07:00
kshitijk4poor
875a72e4c8 fix: normalize httpx.URL base_url + strip thinking signatures for third-party endpoints
Two linked fixes for MiniMax Anthropic-compatible fallback:

1. Normalize httpx.URL to str before calling .rstrip() in auth/provider
   detection helpers. Some client objects expose base_url as httpx.URL,
   not str — crashed with AttributeError in _requires_bearer_auth() and
   _is_third_party_anthropic_endpoint(). Also fixes _try_activate_fallback()
   to use the already-stringified fb_base_url instead of raw httpx.URL.

2. Strip Anthropic-proprietary thinking block signatures when targeting
   third-party Anthropic-compatible endpoints (MiniMax, Azure AI Foundry,
   self-hosted proxies). These endpoints cannot validate Anthropic's
   signatures and reject them with HTTP 400 'Invalid signature in
   thinking block'. Now threads base_url through convert_messages_to_anthropic()
   → build_anthropic_kwargs() so signature management is endpoint-aware.

Based on PR #4945 by kshitijk4poor (rstrip fix).
Fixes #4944.
2026-04-08 16:39:29 -07:00
Teknium
20a5e589c6 docs: clarify that provider "main" is for auxiliary tasks only (#6291)
Users were setting model.provider to "main" after reading the auxiliary
provider docs, causing "Unknown provider" errors. The "main" alias is
only valid inside auxiliary:, compression:, and fallback_model: configs
where it means "use the same provider as my main agent chat."

Added warning admonitions and inline clarifications to:
- configuration.md: Auxiliary Models provider list and Provider Options table
- fallback-providers.md: Provider Options for Auxiliary Tasks table

Reported by community member cn on Discord.
2026-04-08 16:39:17 -07:00
Teknium
7156f8d866 fix: CI test failures — metadata key, cli console, docker env, vision order (#6294)
Fixes 9 test failures on current main, incorporating ideas from PR stack
#6219-#6222 by xinbenlv with corrections:

- model_metadata: sync HF context length key casing
  (minimaxai/minimax-m2.5 → MiniMaxAI/MiniMax-M2.5)

- cli.py: route quick command error output through self.console
  instead of creating a new ChatConsole() instance

- docker.py: explicit docker_forward_env entries now bypass the
  Hermes secret blocklist (intentional opt-in wins over generic filter)

- auxiliary_client: revert _read_main_provider() to simple
  provider.strip().lower() — the _normalize_aux_provider() call
  introduced in 5c03f2e7 stripped the custom: prefix, breaking
  named custom provider resolution

- auxiliary_client: flip vision auto-detection order to
  active provider → OpenRouter → Nous → stop (was OR → Nous → active)

- test: update vision priority test to match new order

Based on PR #6219-#6222 by xinbenlv.
2026-04-08 16:37:05 -07:00
Siddharth Balyan
8de91ce9d2 fix(nix): make addToSystemPackages fully functional for interactive CLI (#6317)
* fix(nix): export HERMES_HOME system-wide when addToSystemPackages is true

The `addToSystemPackages` option's documentation (and the `:::tip` block in
`website/docs/getting-started/nix-setup.md`) promises that enabling it both
puts the `hermes` CLI on PATH and sets `HERMES_HOME` system-wide so interactive
shells share state with the gateway service. The module only did the former,
so running `hermes` in a user shell silently created a separate `~/.hermes/`
directory instead of the managed `${stateDir}/.hermes`.

Implement the documented behavior by also setting
`environment.variables.HERMES_HOME = "${cfg.stateDir}/.hermes"` in the same
mkIf block, and update the option description to match.

Fixes #6044

* fix(nix): preserve group-readable permissions in managed mode

The NixOS module sets HERMES_HOME directories to 0750 and files to 0640
so interactive users in the hermes group can share state with the gateway
service. Two issues prevented this from working:

1. hermes_cli/config.py: _secure_dir() unconditionally chmod'd HERMES_HOME
   to 0700 on every startup, overwriting the NixOS module's 0750. Similarly,
   _secure_file() forced 0600 on config files. Both now skip in managed mode
   (detected via .managed marker or HERMES_MANAGED env var).

2. nix/nixosModules.nix: the .env file was created with 0600 (owner-only),
   while config.yaml was already 0640 (group-readable). Changed to 0640 for
   consistency — users granted hermes group membership should be able to read
   the managed .env.

Verified with a NixOS VM integration test: a normal user in the hermes group
can now run `hermes version` and `hermes config` against the managed
HERMES_HOME without PermissionError.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: zerone0x <zerone0x@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 04:09:53 +05:30
yyovil
8385f54e98 fix(nix): preserve voice deps on aarch64-darwin via nixpkgs (#5079)
* Fixes the nix profile installation for hermes agent

(cherry picked from commit c822a082a8c0ce33f3d406e6b2ae1b2833071df0)

* Update nix/python.nix

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Applied gating for aarch64-darwin platform

Entire-Checkpoint: 1ab2074bd4f1

---------

Co-authored-by: yyovil <tanishq231003@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-09 03:39:39 +05:30
Teknium
105caa001b chore: regenerate uv.lock against current main 2026-04-08 13:47:08 -07:00
jjovalle99
d46db0a1b4 fix(tools): use correct import path for mistralai SDK
mistralai v2.x is a namespace package — `Mistral` class lives at
`mistralai.client`, not at the top-level `mistralai` module. The
previous `from mistralai import Mistral` raises ImportError at runtime.

Update both production code and test fixture to use the correct path.
2026-04-08 13:47:08 -07:00
jjovalle99
5f4b93c20f feat(tools): add Voxtral Transcribe STT provider (Mistral AI) 2026-04-08 13:47:08 -07:00
Teknium
5d2fc6d928 fix: cleanup Qwen OAuth provider gaps
- Add HERMES_QWEN_BASE_URL to OPTIONAL_ENV_VARS in config.py (was missing
  despite being referenced in code)
- Remove redundant qwen-oauth entry from _API_KEY_PROVIDER_AUX_MODELS
  (non-aggregator providers use their main model for aux tasks automatically)
2026-04-08 13:46:30 -07:00
kshitijk4poor
3377017eb4 feat(qwen): add Qwen OAuth provider with portal request support
Based on #6079 by @tunamitom with critical fixes and comprehensive tests.

Changes from #6079:
- Fix: sanitization overwrite bug — Qwen message prep now runs AFTER codex
  field sanitization, not before (was silently discarding Qwen transforms)
- Fix: missing try/except AuthError in runtime_provider.py — stale Qwen
  credentials now fall through to next provider on auto-detect
- Fix: 'qwen' alias conflict — bare 'qwen' stays mapped to 'alibaba'
  (DashScope); use 'qwen-portal' or 'qwen-cli' for the OAuth provider
- Fix: hardcoded ['coder-model'] replaced with live API fetch + curated
  fallback list (qwen3-coder-plus, qwen3-coder)
- Fix: extract _is_qwen_portal() helper + _qwen_portal_headers() to replace
  5 inline 'portal.qwen.ai' string checks and share headers between init
  and credential swap
- Fix: add Qwen branch to _apply_client_headers_for_base_url for mid-session
  credential swaps
- Fix: remove suspicious TypeError catch blocks around _prompt_provider_choice
- Fix: handle bare string items in content lists (were silently dropped)
- Fix: remove redundant dict() copies after deepcopy in message prep
- Revert: unrelated ai-gateway test mock removal and model_switch.py comment deletion

New tests (30 test functions):
- _qwen_cli_auth_path, _read_qwen_cli_tokens (success + 3 error paths)
- _save_qwen_cli_tokens (roundtrip, parent creation, permissions)
- _qwen_access_token_is_expiring (5 edge cases: fresh, expired, within skew,
  None, non-numeric)
- _refresh_qwen_cli_tokens (success, preserve old refresh, 4 error paths,
  default expires_in, disk persistence)
- resolve_qwen_runtime_credentials (fresh, auto-refresh, force-refresh,
  missing token, env override)
- get_qwen_auth_status (logged in, not logged in)
- Runtime provider resolution (direct, pool entry, alias)
- _build_api_kwargs (metadata, vl_high_resolution_images, message formatting,
  max_tokens suppression)
2026-04-08 13:46:30 -07:00
Teknium
a1213d06bd fix(hindsight): correct config key mismatch and add base URL support (#6282)
Fixes #6259. Three bugs fixed:

1. Config key mismatch: _get_client() and _start_daemon() read
   'llmApiKey' (camelCase) but save_config() stores 'llm_api_key'
   (snake_case). The config value was never read — only the env var
   fallback worked.

2. Missing base URL support: users on OpenRouter or custom endpoints
   had no way to configure HINDSIGHT_API_LLM_BASE_URL through setup.
   Added llm_base_url to config schema with empty default, passed
   conditionally to HindsightEmbedded constructor.

3. Daemon config change detection: config_changed now also checks
   HINDSIGHT_API_LLM_BASE_URL, and the daemon profile .env includes
   the base URL when set.

Keeps HINDSIGHT_API_LLM_API_KEY (with double API) in the daemon
profile .env — this matches the upstream hindsight .env.example
convention.
2026-04-08 13:46:14 -07:00
Teknium
1631895d5a docs(telegram): add proxy support section
Documents the proxy env var support added in PR #3591 (salvage of #3411
by @kufufu9). Covers HTTPS_PROXY/HTTP_PROXY/ALL_PROXY precedence,
configuration methods, and scope.
2026-04-08 13:45:14 -07:00
Teknium
4f467700d4 fix(doctor): only check the active memory provider, not all providers unconditionally (#6285)
* fix(tools): skip camofox auto-cleanup when managed persistence is enabled

When managed_persistence is enabled, cleanup_browser() was calling
camofox_close() which destroys the server-side browser context via
DELETE /sessions/{userId}, killing login sessions across cron runs.

Add camofox_soft_cleanup() — a public wrapper that drops only the
in-memory session entry when managed persistence is on, returning True.
When persistence is off it returns False so the caller falls back to
the full camofox_close().  The inactivity reaper still handles idle
resource cleanup.

Also surface a logger.warning() when _managed_persistence_enabled()
fails to load config, replacing a silent except-and-return-False.

Salvaged from #6182 by el-analista (Eduardo Perea Fernandez).
Added public API wrapper to avoid cross-module private imports,
and test coverage for both persistence paths.

Co-authored-by: Eduardo Perea Fernandez <el-analista@users.noreply.github.com>

* fix(doctor): only check the active memory provider, not all providers unconditionally

hermes doctor had hardcoded Honcho Memory and Mem0 Memory sections that
always ran regardless of the user's memory.provider config setting. After
the swappable memory provider update (#4623), users with leftover Honcho
config but no active provider saw false 'broken' errors.

Replaced both sections with a single Memory Provider section that reads
memory.provider from config.yaml and only checks the configured provider.
Users with no external provider see a green 'Built-in memory active' check.

Reported by community user michaelruiz001, confirmed by Eri (Honcho).

---------

Co-authored-by: Eduardo Perea Fernandez <el-analista@users.noreply.github.com>
2026-04-08 13:44:58 -07:00
Teknium
ff6a86cb52 docs: update v0.8.0 highlights — notify_on_complete, MiMo v2 Pro, reorder 2026-04-08 04:59:45 -07:00
Teknium
86960cdbb0 chore: release v0.8.0 (2026.4.8) (#6135) 2026-04-08 04:56:20 -07:00
Teknium
8b0afa0e57 fix: aggressive worktree and branch cleanup to prevent accumulation (#6134)
Problem: hermes -w sessions accumulated 37+ worktrees and 1200+ orphaned
branches because:
- _cleanup_worktree bailed on any dirty working tree, but agent sessions
  almost always leave untracked files/artifacts behind
- _prune_stale_worktrees had the same dirty-check, so stale worktrees
  survived indefinitely
- pr-* and hermes/* branches from PR review had zero cleanup mechanism

Changes:
- _cleanup_worktree: check for unpushed commits instead of dirty state.
  Agent work lives in pushed commits/PRs — dirty working tree without
  unpushed commits is just artifacts, safe to remove.
- _prune_stale_worktrees: three-tier age system:
  - Under 24h: skip (session may be active)
  - 24h-72h: remove if no unpushed commits
  - Over 72h: force remove regardless
- New _prune_orphaned_branches: on each -w startup, deletes local
  hermes/hermes-* and pr-* branches with no corresponding worktree.
  Protects main, checked-out branch, and active worktree branches.

Tests: 42 pass (6 new covering unpushed-commit logic, force-prune
tier, and orphaned branch cleanup).
2026-04-08 04:44:49 -07:00
Teknium
ab21fbfd89 fix: add gateway coverage for session boundary hooks, move test to tests/cli/
- Fire on_session_finalize and on_session_reset in gateway _handle_reset_command()
- Fire on_session_finalize during gateway stop() for each active agent
- Move CLI test from tests/ root to tests/cli/ (matches recent restructure)
- Add 5 gateway tests covering reset hooks, ordering, shutdown, and error handling
- Place on_session_reset after new session is guaranteed to exist (covers
  the get_or_create_session fallback path)
2026-04-08 04:27:34 -07:00
Felipe de Leon
bdc72ec355 feat(cli): add on_session_finalize and on_session_reset plugin hooks
Plugins can now subscribe to session boundary events via
ctx.register_hook('on_session_finalize', ...) and
ctx.register_hook('on_session_reset', ...).

on_session_finalize — fires during CLI exit (/quit, Ctrl-C) and
before /new or /reset, giving plugins a chance to flush or clean up.

on_session_reset — fires after a new session is created via
/new or /reset, so plugins can initialize per-session state.

Closes #5592
2026-04-08 04:27:34 -07:00
Teknium
c8a5e36be8 feat(prompting): self-optimized GPT/Codex tool-use guidance via automated behavioral benchmarking (#6120)
Hermes Agent identified and patched its own prompting blind spots through
automated self-evaluation — running 64+ tool-use benchmarks across GPT-5.4
and Codex-5.3, diagnosing 5 failure modes, writing targeted prompt patches,
and verifying the fix in a closed loop.

Failure modes discovered and fixed:
- Mental arithmetic (wrong answers: 39,152,053 vs correct 39,151,253)
- User profile hallucination ('Windows 11' when running on Linux)
- Time guessing without verification
- Clarification-seeking instead of acting ('open where?' for port checks)
- Hash computation from memory (SHA-256, encodings)
- Confusing system RAM with agent's own persistent memory store

Two new XML sections added to OPENAI_MODEL_EXECUTION_GUIDANCE:
- <mandatory_tool_use>: explicit categories that must always use tools
- <act_dont_ask>: default to action on obvious interpretations

Results:
  gpt-5.4:       68.8% → 100% tool compliance (+31.2pp)
  gpt-5.3-codex: 62.5% → 100% tool compliance (+37.5pp)
  Regression:    0/8 conversational prompts over-tooled
2026-04-08 04:06:42 -07:00
Teknium
1368caf66f fix(anthropic): smart thinking block signature management (#6112)
Anthropic signs thinking blocks against the full turn content. Any
upstream mutation (context compression, session truncation, orphan
stripping, message merging) invalidates the signature, causing HTTP 400
'Invalid signature in thinking block' — especially in long-lived
gateway sessions.

Strategy (following clawdbot/OpenClaw pattern):

1. Strip thinking/redacted_thinking from all assistant messages EXCEPT
   the last one — preserves reasoning continuity on the current
   tool-use chain while avoiding stale signature errors on older turns.

2. Downgrade unsigned thinking blocks to plain text — Anthropic can't
   validate them, but the reasoning content is preserved.

3. Strip cache_control from thinking/redacted_thinking blocks to
   prevent cache markers from interfering with signature validation.

4. Drop thinking blocks from the second message when merging
   consecutive assistant messages (role alternation enforcement).

5. Error recovery: on HTTP 400 mentioning 'signature' and 'thinking',
   strip all reasoning_details from the conversation and retry once.
   This is the safety net for edge cases the proactive stripping
   misses.

Addresses the issue reported in PR #6086 by @mingginwan while
preserving reasoning continuity (their PR stripped ALL thinking
blocks unconditionally).

Files changed:
- agent/anthropic_adapter.py: thinking block management in
  convert_messages_to_anthropic (strip old turns, downgrade unsigned,
  strip cache_control, merge-time strip)
- run_agent.py: one-shot signature error recovery in retry loop
- tests/test_anthropic_adapter.py: 10 new tests covering all cases
2026-04-08 03:38:08 -07:00
Teknium
30ea423ce8 fix: unify reasoning_effort to config.yaml only, remove HERMES_REASONING_EFFORT env var
Gateway and cron had inconsistent reasoning_effort resolution:
- CLI: config.yaml only (correct)
- Gateway: config.yaml first, env var fallback
- Cron: env var first, config.yaml fallback

All three now read exclusively from agent.reasoning_effort in config.yaml.
Removed HERMES_REASONING_EFFORT env var support entirely — .env is for
secrets only, not behavioral config.
2026-04-08 03:36:44 -07:00
mrshu
19b0ddce40 fix(process): correct detached crash recovery state
Previously crash recovery recreated detached sessions as if they were
fully managed, so polls and kills could lie about liveness and the
checkpoint could forget recovered jobs after the next restart.
This commit refreshes recovered host-backed sessions from real PID
state, keeps checkpoint data durable, and preserves notify watcher
metadata while treating sandbox-only PIDs as non-recoverable.

- Persist `pid_scope` in `tools/process_registry.py` and skip
  recovering sandbox-backed entries without a host-visible PID handle
- Refresh detached sessions on access so `get`/`poll`/`wait` and active
  session queries observe exited processes instead of hanging forever
- Allow recovered host PIDs to be terminated honestly and requeue
  `notify_on_complete` watchers during checkpoint recovery
- Add regression tests for durable checkpoints, detached exit/kill
  behavior, sandbox skip logic, and recovered notify watchers
2026-04-08 03:35:43 -07:00
landy
383db35925 fix: improve streaming fallback after edit failures 2026-04-08 03:33:43 -07:00
史官
55ac056920 fix(hindsight): add missing get_hermes_home import
Import hermes_constants.get_hermes_home at module level so it is
available in _start_daemon() when local mode starts the embedded
daemon. Previously the import was only inside _load_config(), causing
NameError when _start_daemon() referenced get_hermes_home().

Fixes #5993

Co-Authored-By: 史官 <historian@slock.team>
2026-04-08 03:18:04 -07:00
Vasanthdev2004
085c1c6875 fix(browser): preserve agent-browser paths with spaces 2026-04-08 02:35:48 -07:00
Teknium
a18e5b95ad docs: add Hermes Mod visual skin editor section to skins page (#6095)
Add documentation for cocktailpeanut's hermes-mod community tool —
a web UI for creating and managing Hermes skins visually. Covers
installation (Pinokio, npx, manual), usage walkthrough, and feature
overview including ASCII art generation from images.

Ref: https://github.com/cocktailpeanut/hermes-mod
2026-04-08 02:28:40 -07:00
Teknium
3696c74bfb fix: preserve existing thresholds, remove pre-read byte guard
- DEFAULT_RESULT_SIZE_CHARS: 50K -> 100K (match current _LARGE_RESULT_CHARS)
- DEFAULT_PREVIEW_SIZE_CHARS: 2K -> 1.5K (match current _LARGE_RESULT_PREVIEW_CHARS)
- Per-tool overrides all set to 100K (terminal, execute_code, search_files)
- Remove pre-read byte guard (no behavioral regression vs current main)
- Revert limit signature change to int=500 (match current default)
- Restore original read_file schema description
- Update test assertions to match 100K thresholds
2026-04-08 02:24:32 -07:00
alt-glitch
bbcff8dcd0 fix(tools): address PR review — remove _extract_raw_output, BudgetConfig everywhere, read_file hardening
- Remove _extract_raw_output: persist content verbatim (fixes size mismatch bug)
- Drop import aliases: import from budget_config directly, one canonical name
- BudgetConfig param on maybe_persist_tool_result and enforce_turn_budget
- read_file: limit=None signature, pre-read guard fires only when limit omitted (256KB)
- Unify binary extensions: file_operations.py imports from binary_extensions.py
- Exclude .pdf and .svg from binary set (text-based, agents may inspect)
- Remove redundant outer try/except in eval path (internal fallback handles it)
- Fix broken tests: update assertion strings for new persistence format
- Module-level constants: _PRE_READ_MAX_BYTES, _DEFAULT_READ_LIMIT
- Remove redundant pathlib import (Path already at module level)
- Update spec.md with IMPLEMENTED annotations and design decisions
2026-04-08 02:24:32 -07:00
alt-glitch
77c5bc9da9 feat(budget): make tool result persistence thresholds configurable
Add BudgetConfig dataclass to centralize and make overridable the
hardcoded constants (50K per-result, 200K per-turn, 2K preview) that
control when tool outputs get persisted to sandbox. Configurable at
the RL environment level via HermesAgentEnvConfig fields, threaded
through HermesAgentLoop to the storage layer.

Resolution: pinned (read_file=inf) > env config overrides > registry
per-tool > default. CLI override: --env.turn_budget_chars 80000
2026-04-08 02:24:32 -07:00
alt-glitch
65e24c942e wip: tool result fixes -- persistence 2026-04-08 02:24:32 -07:00
kshitij
22d1bda185 fix(minimax): correct context lengths, model catalog, thinking guard, aux model, and config base_url
Cherry-picked from PR #6046 by kshitijk4poor with dead code stripped.

- Context lengths: 204800 → 1M (M1) / 1048576 (M2.5/M2.7) per official docs
- Model catalog: add M1 family, remove deprecated M2.1 and highspeed variants
- Thinking guard: skip extended thinking for MiniMax (Anthropic-compat endpoint)
- Aux model: MiniMax-M2.7-highspeed → MiniMax-M2.7 (same model, half price)
- Config base_url: honour model.base_url for API-key providers (fixes China users)
- Stripped unused get_minimax_max_output() / _MINIMAX_MAX_OUTPUT (no consumer)

Fixes #5777, #4082, #6039. Closes #3895.
2026-04-08 02:20:46 -07:00
Mibayy
ab271ebe10 fix(vision): simplify vision auto-detection to openrouter → nous → active provider
Simplify the vision auto-detection chain from 5 backends (openrouter,
nous, codex, anthropic, custom) down to 3:

  1. OpenRouter  (known vision-capable default model)
  2. Nous Portal (known vision-capable default model)
  3. Active provider + model (whatever the user is running)
  4. Stop

This is simpler and more predictable. The active provider step uses
resolve_provider_client() which handles all provider types including
named custom providers (from #5978).

Removed the complex preferred-provider promotion logic and API-level
fallback — the chain is short enough that it doesn't need them.

Based on PR #5376 by Mibay. Closes #5366.
2026-04-08 01:21:54 -07:00
zocomputer
e1befe5077 feat(agent): add jittered retry backoff
Adds agent/retry_utils.py with jittered_backoff() — exponential backoff
with additive jitter to prevent thundering-herd retry spikes when
multiple gateway sessions hit the same rate-limited provider.

Replaces fixed exponential backoff at 4 call sites:
- run_agent.py: None-choices retry path (5s base, 120s cap)
- run_agent.py: API error retry path (2s base, 60s cap)
- trajectory_compressor.py: sync + async summarization retries

Thread-safe jitter counter with overflow guards ensures unique seeds
across concurrent retries.

Trimmed from original PR to keep only wired-in functionality.

Co-authored-by: martinp09 <martinp09@users.noreply.github.com>
2026-04-08 00:41:36 -07:00
Teknium
fff237e111 feat(cron): track delivery failures in job status (#6042)
_deliver_result() now returns Optional[str] — None on success, error
message on failure. All failure paths (unknown platform, platform
disabled, config load error, send failure, unresolvable target)
return descriptive error strings.

mark_job_run() gains delivery_error param, tracked as
last_delivery_error on the job — separate from agent execution errors.
A job where the agent succeeded but delivery failed shows
last_status='ok' + last_delivery_error='...'.

The cronjob list tool now surfaces last_delivery_error so agents and
users can see when cron outputs aren't arriving.

Inspired by PR #5863 (oxngon) — reimplemented with proper wiring.

Tests: 3 new mark_job_run tests + 6 new _deliver_result return tests.
2026-04-07 22:49:01 -07:00
Teknium
598c25d43e feat(feishu): add interactive card approval buttons (#6043)
Add button-based exec approval to the Feishu adapter, matching the
existing Discord, Telegram, and Slack implementations.

When the agent encounters a dangerous command, Feishu users now see
an interactive card with four buttons instead of text instructions:
- Allow Once (primary)
- Allow Session
- Always Allow
- Deny (danger)

Implementation:
- send_exec_approval() sends an interactive card via the Feishu
  message API with buttons carrying hermes_action in their value dict
- _handle_card_action_event() intercepts approval button clicks
  before routing them as synthetic commands, directly calling
  resolve_gateway_approval() to unblock the agent thread
- _update_approval_card() replaces the orange approval card with a
  green (approved) or red (denied) status card showing who acted
- _approval_state dict tracks pending approval_id → session_key
  mappings; cleaned up on resolution

The gateway's existing routing in _approval_notify_sync already checks
getattr(type(adapter), 'send_exec_approval', None) and will
automatically use the button-based flow for Feishu.

Tests: 16 new tests covering send, callback resolution, state
management, card updates, and non-interference with existing card
actions.
2026-04-07 22:45:14 -07:00
Teknium
5c03f2e7cc fix: provider/model resolution — salvage 4 PRs + MiniMax aux URL fix (#5983)
Salvaged fixes from community PRs:

- fix(model_switch): _read_auth_store → _load_auth_store + fix auth store
  key lookup (was checking top-level dict instead of store['providers']).
  OAuth providers now correctly detected in /model picker.
  Cherry-picked from PR #5911 by Xule Lin (linxule).

- fix(ollama): pass num_ctx to override 2048 default context window.
  Ollama defaults to 2048 context regardless of model capabilities. Now
  auto-detects from /api/show metadata and injects num_ctx into every
  request. Config override via model.ollama_num_ctx. Fixes #2708.
  Cherry-picked from PR #5929 by kshitij (kshitijk4poor).

- fix(aux): normalize provider aliases for vision/auxiliary routing.
  Adds _normalize_aux_provider() with 17 aliases (google→gemini,
  claude→anthropic, glm→zai, etc). Fixes vision routing failure when
  provider is set to 'google' instead of 'gemini'.
  Cherry-picked from PR #5793 by e11i (Elizabeth1979).

- fix(aux): rewrite MiniMax /anthropic base URLs to /v1 for OpenAI SDK.
  MiniMax's inference_base_url ends in /anthropic (Anthropic Messages API),
  but auxiliary client uses OpenAI SDK which appends /chat/completions →
  404 at /anthropic/chat/completions. Generic _to_openai_base_url() helper
  rewrites terminal /anthropic to /v1 for OpenAI-compatible endpoint.
  Inspired by PR #5786 by Lempkey.

Added debug logging to silent exception blocks across all fixes.

Co-authored-by: Hermes Agent <hermes@nousresearch.com>
2026-04-07 22:23:28 -07:00
Teknium
8d7a98d2ff feat: use mimo-v2-pro for non-vision auxiliary tasks on Nous free tier (#6018)
Free-tier Nous Portal users were getting mimo-v2-omni (a multimodal
model) for all auxiliary tasks including compression, session search,
and web extraction. Now routes non-vision tasks to mimo-v2-pro (a
text model) which is better suited for those workloads.

- Added _NOUS_FREE_TIER_AUX_MODEL constant for text auxiliary tasks
- _try_nous() accepts vision=False param to select the right model
- Vision path (_resolve_strict_vision_backend) passes vision=True
- All other callers default to vision=False → mimo-v2-pro
2026-04-07 21:41:05 -07:00
Jonathan Barket
7fe6782a25 feat(tools): add "no_mcp" sentinel to exclude MCP servers per platform
Currently, MCP servers are included on all platforms by default. If a
platform's toolset list does not explicitly name any MCP servers, every
globally enabled MCP server is injected. There is no way to opt a
platform out of MCP servers entirely.

This matters for the API server platform when used as an execution
backend — each spawned agent session gets the full MCP tool schema
injected into its system prompt, dramatically inflating token usage
(e.g. 57K tokens vs 9K without MCP tools) and slowing response times.

Add a "no_mcp" sentinel value for platform_toolsets. When present in a
platform's toolset list, all MCP servers are excluded for that platform.
Other platforms are unaffected.

Usage in config.yaml:

    platform_toolsets:
      api_server:
        - terminal
        - file
        - web
        - no_mcp    # exclude all MCP servers

The sentinel is filtered out of the final toolset — it does not appear
as an actual toolset name.
2026-04-07 18:00:01 -07:00
Teknium
b9a5e6e247 fix: use camelCase structuredContent attr, prefer structured over text
- The MCP SDK Pydantic model uses camelCase (structuredContent), not
  snake_case (structured_content). The original getattr was a silent no-op.
- When structuredContent is present, return it AS the result instead of
  alongside text — the structured payload is the machine-readable data.
- Move test file to tests/tools/ and fix fake class to use camelCase.
- Patch _run_on_mcp_loop in tests so the handler actually executes.
2026-04-07 18:00:01 -07:00
r266-tech
363c5bc3c3 test(mcp): add structured_content preservation tests 2026-04-07 18:00:01 -07:00
r266-tech
2ad7694874 fix(mcp): preserve structured_content in tool call results
MCP CallToolResult may include structured_content (a JSON object) alongside
content blocks. The tool handler previously only forwarded concatenated text
from content blocks, silently dropping the structured payload.

This breaks MCP tools that return a minimal human text in content while
putting the actual machine-usable payload in structured_content.

Now, when structured_content is present, it is included in the returned
JSON under the 'structuredContent' key.

Fixes NousResearch/hermes-agent#5874
2026-04-07 18:00:01 -07:00
Teknium
cbf1f15cfe fix(auxiliary): resolve named custom providers and 'main' alias in auxiliary routing (#5978)
* fix(telegram): replace substring caption check with exact line-by-line match

Captions in photo bursts and media group albums were silently dropped when
a shorter caption happened to be a substring of an existing one (e.g.
"Meeting" lost inside "Meeting agenda"). Extract a shared _merge_caption
static helper that splits on "\n\n" and uses exact match with whitespace
normalisation, then use it in both _enqueue_photo_event and
_queue_media_group_event.

Adds 13 unit tests covering the fixed bug scenarios.

Cherry-picked from PR #2671 by Dilee.

* fix: extend caption substring fix to all platforms

Move _merge_caption helper from TelegramAdapter to BasePlatformAdapter
so all adapters inherit it. Fix the same substring-containment bug in:
- gateway/platforms/base.py (photo burst merging)
- gateway/run.py (priority photo follow-up merging)
- gateway/platforms/feishu.py (media batch merging)

The original fix only covered telegram.py. The same bug existed in base.py
and run.py (pure substring check) and feishu.py (list membership without
whitespace normalization).

* fix(auxiliary): resolve named custom providers and 'main' alias in auxiliary routing

Two bugs caused auxiliary tasks (vision, compression, etc.) to fail when
using named custom providers defined in config.yaml:

1. 'provider: main' was hardcoded to 'custom', which only checks legacy
   OPENAI_BASE_URL env vars. Now reads _read_main_provider() to resolve
   to the actual provider (e.g., 'custom:beans', 'openrouter', 'deepseek').

2. Named custom provider names (e.g., 'beans') fell through to
   PROVIDER_REGISTRY which doesn't know about config.yaml entries.
   Now checks _get_named_custom_provider() before the registry fallback.

Fixes both resolve_provider_client() and _normalize_vision_provider()
so the fix covers all auxiliary tasks (vision, compression, web_extract,
session_search, etc.).

Adds 13 unit tests. Reported by Laura via Discord.

---------

Co-authored-by: Dilee <uzmpsk.dilekakbas@gmail.com>
2026-04-07 17:59:47 -07:00
Teknium
9692b3c28a fix: CLI/UX batch — ChatConsole errors, curses scroll, skin-aware banner, git state banner (#5974)
* fix(cli): route error messages through ChatConsole inside patch_stdout

Cherry-pick of PR #5798 by @icn5381.

Replace self.console.print() with ChatConsole().print() for 11 error/status
messages reachable during the interactive session. Inside patch_stdout,
self.console (plain Rich Console) writes raw ANSI escapes that StdoutProxy
mangles into garbled text. ChatConsole uses prompt_toolkit's native
print_formatted_text which renders correctly.

Same class of bug as #2262 — that fix covered agent output but missed
these error paths in _ensure_runtime_credentials, _init_agent, quick
commands, skill loading, and plan mode.

* fix(model-picker): add scrolling viewport to curses provider menu

Cherry-pick of PR #5790 by @Lempkey. Fixes #5755.

_curses_prompt_choice rendered items starting unconditionally from index 0
with no scroll offset. The 'More providers' submenu has 13 entries. On
terminals shorter than ~16 rows, items past the fold were never drawn.
When UP-arrow wrapped cursor from 0 to the last item (Cancel, index 12),
the highlight rendered off-screen — appearing as if only Cancel existed.

Adds scroll_offset tracking that adjusts each frame to keep the cursor
inside the visible window.

* feat(cli): skin-aware compact banner + git state in startup banner

Combined salvage of PR #5922 by @ASRagab and PR #5877 by @xinbenlv.

Compact banner changes (from #5922):
- Read active skin colors and branding instead of hardcoding gold/NOUS HERMES
- Default skin preserves backward-compatible legacy branding
- Non-default skins use their own agent_name and colors

Git state in banner (from #5877):
- New format_banner_version_label() shows upstream/local git hashes
- Full banner title now includes git state (upstream hash, carried commits)
- Compact banner line2 shows the version label with git state
- Widen compact banner max width from 64 to 88 to fit version info

Both the full Rich banner and compact fallback are now skin-aware
and show git state.
2026-04-07 17:59:42 -07:00
Teknium
f3c59321af fix: add _profile_arg tests + move STT language to config.yaml
- Add 7 unit tests for _profile_arg: default home, named profile,
  hash path, nested path, invalid name, systemd integration, launchd integration
- Add stt.local.language to config.yaml (empty = auto-detect)
- Both STT code paths now read config.yaml first, env var fallback,
  then default (auto-detect for faster-whisper, 'en' for CLI command)
- HERMES_LOCAL_STT_LANGUAGE env var still works as backward-compat fallback
2026-04-07 17:59:16 -07:00
Marc Bickel
6e02fa73c2 fix(discord): discard empty placeholder on voice transcription + force STT language
- gateway/run.py: Strip "(The user sent a message with no text content)"
  placeholder when voice transcription succeeds — it was being appended
  alongside the transcript, creating duplicate user turns.
- tools/transcription_tools.py: Wire HERMES_LOCAL_STT_LANGUAGE env var
  into the faster-whisper backend. It was only used by the CLI fallback
  path (_transcribe_local_command), not the primary faster-whisper path.
2026-04-07 17:59:16 -07:00
Marc Bickel
25080986a0 fix(gateway): discard empty placeholder when voice transcription succeeds
When a Discord voice message arrives, the adapter sets event.text to
"(The user sent a message with no text content)" since voice messages
have no text content. The transcription enrichment in
_enrich_message_with_transcription() then prepends the transcript but
leaves the placeholder intact, causing the agent to receive both:

    [The user sent a voice message~ Here's what they said: "..."]

    (The user sent a message with no text content)

The agent sees this as two separate user turns — one transcribed
and one empty — creating confusing duplicate messages.

Fix: when the transcription succeeds and user_text is only the empty
placeholder, return just the transcript without the redundant placeholder.
2026-04-07 17:59:16 -07:00
Jarvis AI
c3158d38b2 fix(gateway): include --profile in launchd/systemd argv for named profiles
generate_launchd_plist() and generate_systemd_unit() were missing the
--profile <name> argument in ProgramArguments/ExecStart, causing
hermes gateway start to regenerate plists that fell back to
~/.hermes/active_profile instead of the intended profile.

Fix:
- Add _profile_arg(hermes_home?) helper returning '--profile <name>'
  only for ~/.hermes/profiles/<name> paths, empty string otherwise.
- Update generate_launchd_plist() to build ProgramArguments array
  dynamically with --profile when applicable.
- Update generate_systemd_unit() both user and system service
  branches with {profile_arg} in ExecStart.

This ensures hermes --profile <name> gateway start produces a
service definition that correctly scopes to the named profile.
2026-04-07 17:59:16 -07:00
Teknium
50d1518df6 fix(tests): update tool_progress_callback test calls to new 4-arg signature
Follow-up to sroecker's PR #5918 — test mocks were using the old 3-arg
callback signature (name, preview, args) instead of the new
(event_type, name, preview, args, **kwargs).
2026-04-07 17:56:01 -07:00
pradeep7127
1d5a69a445 fix(api_server): preserve conversation history when /v1/runs input is a message array
When /v1/runs receives an OpenAI-style array of messages as input, all
messages except the last user turn are now extracted as conversation_history.
Previously only the last message was kept, silently discarding earlier
context in multi-turn conversations.

Handles multi-part content blocks by flattening text portions. Only fires
when no explicit conversation_history was provided.

Based on PR #5837 by pradeep7127.
2026-04-07 17:56:01 -07:00
VanBladee
786038443e feat(api): accept conversation_history in request body
Allow clients to pass explicit conversation_history in /v1/responses and
/v1/runs request bodies instead of relying on server-side response chaining
via previous_response_id. Solves problems with stateless deployments where
the in-memory ResponseStore is lost on restart.

Adds input validation (must be array of {role, content} objects) and clear
precedence: explicit conversation_history > previous_response_id.

Based on PR #5805 by VanBladee, with added input validation.
2026-04-07 17:56:01 -07:00
Steffen Röcker
7ec838507a fix(api_server): update tool_progress_callback signature for Open WebUI streaming
Commit cc2b56b2 changed the tool_progress_callback signature from
(name, preview, args) to (event_type, name, preview, args, **kwargs)
but the API server's chat completion streaming callback was not updated.

This caused tool calls to not display in Open WebUI because the
callback received arguments in wrong positions.

- Update _on_tool_progress to use new 4-arg signature
- Add event_type filter to only show tool.started events
- Add **kwargs for optional duration/is_error parameters
2026-04-07 17:56:01 -07:00
Teknium
efbe8d674a docs: add Discord channel controls and Telegram reactions documentation
- Discord: ignored_channels, no_thread_channels config reference + examples
- Telegram: message reactions section with config, behavior notes
- Environment variables reference updated for all new vars
2026-04-07 17:55:55 -07:00
Teknium
a6547f399f test: add tests for Discord channel controls and Telegram reactions
- 14 tests for ignored_channels, no_thread_channels, and config bridging
- 17 tests for reaction enable/disable, API calls, error handling, and config
2026-04-07 17:55:55 -07:00
Teknium
52b3a3ca3a fix: default Telegram reactions to off, remove dead _remove_reaction
Telegram's set_message_reaction replaces all reactions in one call,
so _remove_reaction was never called (unlike Discord's additive model).
Default reactions to disabled — users opt in via telegram.reactions: true.
2026-04-07 17:55:55 -07:00
Alvaro Linares
74b0072f8f feat(telegram): add message reactions on processing start/complete
Mirror the Discord reaction pattern for Telegram:
- 👀 (eyes) when message processing begins
-  (check) on successful completion
-  (cross) on failure

Controlled via TELEGRAM_REACTIONS env var or telegram.reactions
in config.yaml (enabled by default, like Discord).

Uses python-telegram-bot's Bot.set_message_reaction() API.
Failures are caught and logged at debug level so they never
break message processing.
2026-04-07 17:55:55 -07:00
Angello Picasso
f6d4b6a319 feat(discord): add ignored_channels and no_thread_channels config
- ignored_channels: channels where bot never responds (even when mentioned)
- no_thread_channels: channels where bot responds directly without thread

Both support config.yaml and env vars (DISCORD_IGNORED_CHANNELS,
DISCORD_NO_THREAD_CHANNELS), following existing pattern for
free_response_channels.

Fixes #5881
2026-04-07 17:55:55 -07:00
lesterli
37bf19a29d fix(codex): align validation with normalization for empty stream output
The response validation stage unconditionally marked Codex Responses API
replies as invalid when response.output was empty, triggering unnecessary
retries and fallback chains. However, _normalize_codex_response can
recover from this state by synthesizing output from response.output_text.

Now the validation stage checks for output_text before marking the
response invalid, matching the normalization logic. Also fixes
logging.warning → logger.warning for consistency with the rest of the
file.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 17:29:41 -07:00
Teknium
469cd16fe0 fix(security): consolidated security hardening — SSRF, timing attack, tar traversal, credential leakage (#5944)
Salvaged from PRs #5800 (memosr), #5806 (memosr), #5915 (Ruzzgar), #5928 (Awsh1).

Changes:
- Use hmac.compare_digest for API key comparison (timing attack prevention)
- Apply provider env var blocklist to Docker containers (credential leakage)
- Replace tar.extractall() with safe extraction in TerminalBench2 (CVE-2007-4559)
- Add SSRF protection via is_safe_url to ALL platform adapters:
  base.py (cache_image_from_url, cache_audio_from_url),
  discord, slack, telegram, matrix, mattermost, feishu, wecom
  (Signal and WhatsApp protected via base.py helpers)
- Update tests: mock is_safe_url in Mattermost download tests
- Add security tests for tar extraction (traversal, symlinks, safe files)
2026-04-07 17:28:37 -07:00
Teknium
b1a66d55b4 refactor: migrate 10 config.yaml inline loaders to read_raw_config()
Replace 10 callsites across 6 files that manually opened config.yaml,
called yaml.safe_load(), and handled missing-file/parse-error fallbacks
with the new read_raw_config() helper from hermes_cli/config.py.

Each migrated site previously had 5-8 lines of boilerplate:
    config_path = get_hermes_home() / 'config.yaml'
    if config_path.exists():
        import yaml
        with open(config_path) as f:
            cfg = yaml.safe_load(f) or {}

Now reduced to:
    from hermes_cli.config import read_raw_config
    cfg = read_raw_config()

Migrated files:
- tools/browser_tool.py (4 sites): command_timeout, cloud_provider,
  allow_private_urls, record_sessions
- tools/env_passthrough.py: terminal.env_passthrough
- tools/credential_files.py: terminal.credential_files
- tools/transcription_tools.py: stt.model
- hermes_cli/commands.py: config-gated command resolution
- hermes_cli/auth.py (2 sites): model config read + provider reset

Skipped (intentionally):
- gateway/run.py: 10+ sites with local aliases, critical path
- hermes_cli/profiles.py: profile-specific config path
- hermes_cli/doctor.py: reads raw then writes fixes back
- agent/model_metadata.py: different file (context_length_cache.yaml)
- tools/website_policy.py: custom config_path param + error types
2026-04-07 17:28:23 -07:00
Zainan Victor Zhou
0d41fb0827 fix(gateway): show full session id and title in /status 2026-04-07 17:27:09 -07:00
Jeff Escalante
4aef055805 fix(gateway/webhook): don't pop delivery_info on send
The webhook adapter stored per-request `deliver`/`deliver_extra` config in
`_delivery_info[chat_id]` during POST handling and consumed it via `.pop()`
inside `send()`. That worked for routes whose agent run produced exactly
one outbound message — the final response — but it broke whenever the
agent emitted any interim status message before the final response.

Status messages flow through the same `send(chat_id, ...)` path as the
final response (see `gateway/run.py::_status_callback_sync` →
`adapter.send(...)`). Common triggers include:

  - "🔄 Primary model failed — switching to fallback: ..."
    (run_agent.py::_emit_status when `fallback_providers` activates)
  - context-pressure / compression notices
  - any other lifecycle event routed through `status_callback`

When any of those fired, the first `send()` call popped the entry, so the
subsequent final-response `send()` saw an empty dict and silently
downgraded `deliver_type` from `"telegram"` (or `discord`/`slack`/etc.) to
the default `"log"`. The agent's response was logged to the gateway log
instead of being delivered to the configured cross-platform target — no
warning, no error, just a missing message.

This was easy to hit in practice. Any user with `fallback_providers`
configured saw it the first time their primary provider hiccuped on a
webhook-triggered run. Routes that worked perfectly in dev (where the
primary stays healthy) silently dropped responses in prod.

Fix: read `_delivery_info` with `.get()` so multiple `send()` calls for
the same `chat_id` all see the same delivery config. To keep the dict
bounded without relying on per-send cleanup, add a parallel
`_delivery_info_created` timestamp dict and a `_prune_delivery_info()`
helper that drops entries older than `_idempotency_ttl` (1h, same window
already used by `_seen_deliveries`). Pruning runs on each POST, mirroring
the existing `_seen_deliveries` cleanup pattern.

Worst-case memory footprint is now `rate_limit * TTL = 30/min * 60min =
1800` entries, each ~1KB → under 2 MB. In practice it'll be far smaller
because most webhooks complete in seconds, not the full hour.

Test changes:
  - `test_delivery_info_cleaned_after_send` is replaced with
    `test_delivery_info_survives_multiple_sends`, which is now the
    regression test for this bug — it asserts that two consecutive
    `send()` calls both see the delivery config.
  - A new `test_delivery_info_pruned_via_ttl` covers the TTL cleanup
    behavior.
  - The two integration tests that asserted `chat_id not in
    adapter._delivery_info` after `send()` now assert the opposite, with
    a comment explaining why.

All 40 tests in `tests/gateway/test_webhook_adapter.py` and
`tests/gateway/test_webhook_integration.py` pass. Verified end-to-end
locally against a dynamic `hermes webhook subscribe` route configured
with `--deliver telegram --deliver-chat-id <user>`: with `gpt-5.4` as
the primary (currently flaky) and `claude-opus-4.6` as the fallback,
the fallback notification fires, the agent finishes, and the final
response is delivered to Telegram as expected.
2026-04-07 17:27:09 -07:00
Siddharth Balyan
f3006ebef9 refactor(tests): re-architect tests + fix CI failures (#5946)
* refactor: re-architect tests to mirror the codebase

* Update tests.yml

* fix: add missing tool_error imports after registry refactor

* fix(tests): replace patch.dict with monkeypatch to prevent env var leaks under xdist

patch.dict(os.environ) can leak TERMINAL_ENV across xdist workers,
causing test_code_execution tests to hit the Modal remote path.

* fix(tests): fix update_check and telegram xdist failures

- test_update_check: replace patch("hermes_cli.banner.os.getenv") with
  monkeypatch.setenv("HERMES_HOME") — banner.py no longer imports os
  directly, it uses get_hermes_home() from hermes_constants.

- test_telegram_conflict/approval_buttons: provide real exception classes
  for telegram.error mock (NetworkError, TimedOut, BadRequest) so the
  except clause in connect() doesn't fail with "catching classes that do
  not inherit from BaseException" when xdist pollutes sys.modules.

* fix(tests): accept unavailable_models kwarg in _prompt_model_selection mock
2026-04-07 17:19:07 -07:00
Teknium
99ff375f7a fix(gateway): respect tool_preview_length in all/new progress modes (#5937)
Previously, all/new tool progress modes always hard-truncated previews
to 40 chars, ignoring the display.tool_preview_length config. This made
it impossible for gateway users to see meaningful command/path info
without switching to verbose mode (which shows too much detail).

Now all/new modes read tool_preview_length from config:
- tool_preview_length: 0 (default/unset) → 40 chars (no regression)
- tool_preview_length: 120 → 120-char previews in all/new mode
- verbose mode: unchanged (already respected the config)

Users who want longer previews can set:
  display:
    tool_preview_length: 120

Reported by demontut_ on Discord.
2026-04-07 14:10:56 -07:00
Teknium
125e5ef089 fix: extend caption substring fix to all platforms
Move _merge_caption helper from TelegramAdapter to BasePlatformAdapter
so all adapters inherit it. Fix the same substring-containment bug in:
- gateway/platforms/base.py (photo burst merging)
- gateway/run.py (priority photo follow-up merging)
- gateway/platforms/feishu.py (media batch merging)

The original fix only covered telegram.py. The same bug existed in base.py
and run.py (pure substring check) and feishu.py (list membership without
whitespace normalization).
2026-04-07 14:08:59 -07:00
Dilee
4a630c2071 fix(telegram): replace substring caption check with exact line-by-line match
Captions in photo bursts and media group albums were silently dropped when
a shorter caption happened to be a substring of an existing one (e.g.
"Meeting" lost inside "Meeting agenda"). Extract a shared _merge_caption
static helper that splits on "\n\n" and uses exact match with whitespace
normalisation, then use it in both _enqueue_photo_event and
_queue_media_group_event.

Adds 13 unit tests covering the fixed bug scenarios.

Cherry-picked from PR #2671 by Dilee.
2026-04-07 14:08:59 -07:00
Teknium
7b18eeee9b feat(supermemory): add multi-container, search_mode, identity template, and env var override (#5933)
Based on PR #5413 spec by MaheshtheDev (Mahesh Sanikommu).

Changes:
- Add search_mode config (hybrid/memories/documents) passed to SDK
- Add {identity} template support in container_tag for profile-scoped containers
- Add SUPERMEMORY_CONTAINER_TAG env var override (priority over config)
- Add multi-container mode: enable_custom_container_tags, custom_containers,
  custom_container_instructions in supermemory.json
- Dynamic tool schemas when multi-container enabled (optional container_tag param)
- Whitelist validation for custom container tags in tool calls
- Simplify get_config_schema() to only prompt for API key during setup
- Defer container_tag sanitization to initialize() (after template resolution)
- Add custom_id support to documents.add calls
- Update README with multi-container docs, search_mode, identity template,
  support links (Discord, email)
- Update memory-providers.md with new features and multi-container example
- Update memory-provider-plugin.md with minimal vs full schema guidance
- Add 12 new tests covering identity template, search_mode, multi-container,
  config schema, and env var override
2026-04-07 14:03:46 -07:00
Teknium
678a87c477 refactor: add tool_error/tool_result helpers + read_raw_config, migrate 129 callsites
Add three reusable helpers to eliminate pervasive boilerplate:

tools/registry.py — tool_error() and tool_result():
  Every tool handler returns JSON strings. The pattern
  json.dumps({"error": msg}, ensure_ascii=False) appeared 106 times,
  and json.dumps({"success": False, "error": msg}, ...) another 23.
  Now: tool_error(msg) or tool_error(msg, success=False).

  tool_result() handles arbitrary result dicts:
  tool_result(success=True, data=payload) or tool_result(some_dict).

hermes_cli/config.py — read_raw_config():
  Lightweight YAML reader that returns the raw config dict without
  load_config()'s deep-merge + migration overhead. Available for
  callsites that just need a single config value.

Migration (129 callsites across 32 files):
- tools/: browser_camofox (18), file_tools (10), homeassistant (8),
  web_tools (7), skill_manager (7), cronjob (11), code_execution (4),
  delegate (5), send_message (4), tts (4), memory (7), session_search (3),
  mcp (2), clarify (2), skills_tool (3), todo (1), vision (1),
  browser (1), process_registry (2), image_gen (1)
- plugins/memory/: honcho (9), supermemory (9), hindsight (8),
  holographic (7), openviking (7), mem0 (7), byterover (6), retaindb (2)
- agent/: memory_manager (2), builtin_memory_provider (1)
2026-04-07 13:36:38 -07:00
Teknium
ab8f9c089e feat: thinking-only prefill continuation for structured reasoning responses (#5931)
When the model produces structured reasoning (via API fields like .reasoning,
.reasoning_content, .reasoning_details) but no visible text content, append
the assistant message as prefill and continue the loop. The model sees its own
reasoning context on the next turn and produces the text portion.

Inspired by clawdbot's 'incomplete-text' recovery pattern. Up to 2 prefill
attempts before falling through to the existing '(empty)' terminal.

Key design decisions:
- Only triggers for structured reasoning (API fields), NOT inline <think> tags
- Prefill messages are popped on success to maintain strict role alternation
- _thinking_prefill marker stripped from all API message building paths
- Works across all providers: OpenAI (continuation), Anthropic (native prefill)

Verified with E2E tests: simulated thinking-only → real OpenRouter continuation
produces correct content. Also confirmed Qwen models consistently produce
structured-reasoning-only responses under token pressure.
2026-04-07 13:19:06 -07:00
Teknium
6e2f6a25a1 refactor: deduplicate PowerShell script constants between Windows and WSL paths
Move _PS_CHECK_IMAGE and _PS_EXTRACT_IMAGE above both the native Windows
and WSL2 sections so both can share them. Removes the duplicate
_WIN_PS_CHECK / _WIN_PS_EXTRACT constants.
2026-04-07 12:49:39 -07:00
kshitijk4poor
f4528c885b feat(clipboard): add native Windows image paste support
Add win32 platform branch to clipboard.py so Ctrl+V image paste
works on native Windows (PowerShell / Windows Terminal), not just
WSL2.

Uses the same .NET System.Windows.Forms.Clipboard approach as the
WSL path but calls PowerShell directly instead of powershell.exe
(the WSL cross-call path).  Tries 'powershell' first (Windows
PowerShell 5.1, always available), then 'pwsh' (PowerShell 7+).

PowerShell executable is discovered once and cached for the process
lifetime.

Includes 14 new tests covering:
- Platform dispatch (save_clipboard_image + has_clipboard_image)
- Image detection via PowerShell .NET check
- Base64 PNG extraction and decode
- Edge cases: no PowerShell, empty output, invalid base64, timeout
2026-04-07 12:49:39 -07:00
Teknium
c040b0e4ae test: add unit tests for media helper — video, document, multi-file, failure isolation
Adapted from PR #5679 (0xbyt4) to cover edge cases not in the integration tests:
video routing, unknown extension fallback to send_document, multi-file delivery,
and single-failure isolation.
2026-04-07 12:49:25 -07:00
kshitijk4poor
0f3895ba29 fix(cron): deliver MEDIA files as native platform attachments
The cron delivery path sent raw 'MEDIA:/path/to/file' text instead
of uploading the file as a native attachment.  The standalone path
(via _send_to_platform) already extracted MEDIA tags and forwarded
them as media_files, but the live adapter path passed the unprocessed
delivery_content directly to adapter.send().

Two bugs fixed:
1. Live adapter path now sends cleaned text (MEDIA tags stripped)
   instead of raw content — prevents 'MEDIA:/path' from appearing
   as literal text in Discord/Telegram/etc.
2. Live adapter path now sends each extracted media file via the
   adapter's native method (send_voice for audio, send_image_file
   for images, send_video for video, send_document as fallback) —
   files are uploaded as proper platform attachments.

The file-type routing mirrors BasePlatformAdapter._process_message_background
to ensure consistent behavior between normal gateway responses and
cron-delivered responses.

Adds 2 tests:
- test_live_adapter_sends_media_as_attachments: verifies Discord
  adapter receives send_voice call for .mp3 file
- test_live_adapter_sends_cleaned_text_not_raw: verifies MEDIA tag
  stripped from text sent via live adapter
2026-04-07 12:49:25 -07:00
Teknium
ca0459d109 refactor: remove 24 confirmed dead functions — 432 lines of unused code
Each function was verified to have exactly 1 reference in the entire
codebase (its own definition). Zero calls, zero imports, zero string
references anywhere including tests.

Removed by category:

Superseded wrappers (replaced by newer implementations):
- agent/anthropic_adapter.py: run_hermes_oauth_login, refresh_hermes_oauth_token
- hermes_cli/callbacks.py: sudo_password_callback (superseded by CLI method)
- hermes_cli/setup.py: _set_model_provider, _sync_model_from_disk
- tools/file_tools.py: get_file_tools (superseded by registry.register)
- tools/cronjob_tools.py: get_cronjob_tool_definitions (same)
- tools/terminal_tool.py: _check_dangerous_command (_check_all_guards used)

Dead private helpers (lost their callers during refactors):
- agent/anthropic_adapter.py: _convert_user_content_part_to_anthropic
- agent/display.py: honcho_session_line, write_tty
- hermes_cli/providers.py: _build_labels (+ dead _labels_cache var)
- hermes_cli/tools_config.py: _prompt_yes_no
- hermes_cli/models.py: _extract_model_ids
- hermes_cli/uninstall.py: log_error
- gateway/platforms/feishu.py: _is_loop_ready
- tools/file_operations.py: _read_image (64-line method)
- tools/process_registry.py: cleanup_expired
- tools/skill_manager_tool.py: check_skill_manage_requirements

Dead class methods (zero callers):
- run_agent.py: _is_anthropic_url (logic duplicated inline at L618)
- run_agent.py: _classify_empty_content_response (68-line method, never wired)
- cli.py: reset_conversation (callers all use new_session directly)
- cli.py: _clear_current_input (added but never wired in)

Other:
- gateway/delivery.py: build_delivery_context_for_tool
- tools/browser_tool.py: get_active_browser_sessions
2026-04-07 11:41:26 -07:00
Teknium
69c753c19b fix: thread gateway user_id to memory plugins for per-user scoping (#5895)
Memory plugins (Mem0, Honcho) used static identifiers ('hermes-user',
config peerName) meaning all gateway users shared the same memory bucket.

Changes:
- AIAgent.__init__: add user_id parameter, store as self._user_id
- run_agent.py: include user_id in _init_kwargs passed to memory providers
- gateway/run.py: pass source.user_id to AIAgent in primary + background paths
- Mem0 plugin: prefer kwargs user_id over config default
- Honcho plugin: override cfg.peer_name with gateway user_id when present

CLI sessions (user_id=None) preserve existing defaults. Only gateway
sessions with a real platform user_id get per-user memory scoping.

Reported by plev333.
2026-04-07 11:14:12 -07:00
Teknium
e49c8bbbbb feat(slack): thread engagement — auto-respond in bot-started and mentioned threads (#5897)
When the bot sends a message in a thread, track its ts in _bot_message_ts.
When the bot is @mentioned in a thread, register it in _mentioned_threads.
Both sets enable auto-responding to future messages in those threads
without requiring repeated @mentions — making the bot behave like a
team member that stays engaged once a conversation starts.

Channel message gating now checks 4 signals (in order):
  1. @mention in this message
  2. Reply in a thread the bot started/participated in (_bot_message_ts)
  3. Message in a thread where the bot was previously @mentioned (_mentioned_threads)
  4. Existing session for this thread (_has_active_session_for_thread — survives restarts)

Thread context fetching now triggers on ANY first-entry path (not just
@mention), so the agent gets context whether it's entering via a mention,
a bot-thread reply, or a mentioned-thread auto-trigger.

Both tracking sets are bounded (5000 cap with prune-oldest-half) to prevent
unbounded memory growth in long-running deployments.

Salvaged from PR #5754 by @hhhonzik. Preserves our existing approval buttons,
thread context fetching, and session key fix. Does NOT include the
edit_message format_message() removal (that was a regression in the original PR).

Tests: 4 new tests for bot-ts tracking and mentioned-thread bounds.
2026-04-07 11:12:08 -07:00
Teknium
ab0c1e58f1 fix: pause typing indicator during approval waits (#5893)
When the agent waits for dangerous-command approval, the typing
indicator (_keep_typing loop) kept refreshing. On Slack's Assistant
API this is critical: assistant_threads_setStatus disables the
compose box, preventing users from typing /approve or /deny.

- Add _typing_paused set + pause/resume methods to BasePlatformAdapter
- _keep_typing skips send_typing when chat_id is paused
- _approval_notify_sync pauses typing before sending approval prompt
- _handle_approve_command / _handle_deny_command resume typing after

Benefits all platforms — no reason to show 'is thinking...' while
the agent is idle waiting for human input.
2026-04-07 11:04:50 -07:00
Teknium
1a2a03ca69 feat(gateway): approval buttons for Slack & Telegram + Slack thread context (#5890)
Slack:
- Add Block Kit interactive buttons for command approval (Allow Once,
  Allow Session, Always Allow, Deny) via send_exec_approval()
- Register @app.action handlers for each approval button
- Add _fetch_thread_context() — fetches thread history via
  conversations.replies when bot is first @mentioned mid-thread
- Fix _has_active_session_for_thread() to use build_session_key()
  instead of manual key construction (fixes session key mismatch bug
  where thread_sessions_per_user flag was ignored, ref PR #5833)

Telegram:
- Add InlineKeyboard approval buttons via send_exec_approval()
- Add ea:* callback handling in _handle_callback_query()
- Uses monotonic counter + _approval_state dict to map button clicks
  back to session keys (avoids 64-byte callback_data limit)

Both platforms now auto-detected by the gateway runner's
_approval_notify_sync() — any adapter with send_exec_approval() on
its class gets button-based approval instead of text fallback.

Inspired by community PRs #3898 (LevSky22), #2953 (ygd58), #5833
(heathley). Implemented fresh on current main.

Tests: 24 new tests covering button rendering, action handling,
thread context fetching, session key fix, double-click prevention.
2026-04-07 11:03:14 -07:00
Teknium
187e90e425 refactor: replace inline HERMES_HOME re-implementations with get_hermes_home()
16 callsites across 14 files were re-deriving the hermes home path
via os.environ.get('HERMES_HOME', ...) instead of using the canonical
get_hermes_home() from hermes_constants. This breaks profiles — each
profile has its own HERMES_HOME, and the inline fallback defaults to
~/.hermes regardless.

Fixed by importing and calling get_hermes_home() at each site. For
files already inside the hermes process (agent/, hermes_cli/, tools/,
gateway/, plugins/), this is always safe. Files that run outside the
process context (mcp_serve.py, mcp_oauth.py) already had correct
try/except ImportError fallbacks and were left alone.

Skipped: hermes_constants.py (IS the implementation), env_loader.py
(bootstrap), profiles.py (intentionally manipulates the env var),
standalone scripts (optional-skills/, skills/), and tests.
2026-04-07 10:40:34 -07:00
Teknium
d0ffb111c2 refactor: codebase-wide lint cleanup — unused imports, dead code, and inefficient patterns (#5821)
Comprehensive cleanup across 80 files based on automated (ruff, pyflakes, vulture)
and manual analysis of the entire codebase.

Changes by category:

Unused imports removed (~95 across 55 files):
- Removed genuinely unused imports from all major subsystems
- agent/, hermes_cli/, tools/, gateway/, plugins/, cron/
- Includes imports in try/except blocks that were truly unused
  (vs availability checks which were left alone)

Unused variables removed (~25):
- Removed dead variables: connected, inner, channels, last_exc,
  source, new_server_names, verify, pconfig, default_terminal,
  result, pending_handled, temperature, loop
- Dropped unused argparse subparser assignments in hermes_cli/main.py
  (12 instances of add_parser() where result was never used)

Dead code removed:
- run_agent.py: Removed dead ternary (None if False else None) and
  surrounding unreachable branch in identity fallback
- run_agent.py: Removed write-only attribute _last_reported_tool
- hermes_cli/providers.py: Removed dead @property decorator on
  module-level function (decorator has no effect outside a class)
- gateway/run.py: Removed unused MCP config load before reconnect
- gateway/platforms/slack.py: Removed dead SessionSource construction

Undefined name bugs fixed (would cause NameError at runtime):
- batch_runner.py: Added missing logger = logging.getLogger(__name__)
- tools/environments/daytona.py: Added missing Dict and Path imports

Unnecessary global statements removed (14):
- tools/terminal_tool.py: 5 functions declared global for dicts
  they only mutated via .pop()/[key]=value (no rebinding)
- tools/browser_tool.py: cleanup thread loop only reads flag
- tools/rl_training_tool.py: 4 functions only do dict mutations
- tools/mcp_oauth.py: only reads the global
- hermes_time.py: only reads cached values

Inefficient patterns fixed:
- startswith/endswith tuple form: 15 instances of
  x.startswith('a') or x.startswith('b') consolidated to
  x.startswith(('a', 'b'))
- len(x)==0 / len(x)>0: 13 instances replaced with pythonic
  truthiness checks (not x / bool(x))
- in dict.keys(): 5 instances simplified to in dict
- Redefined unused name: removed duplicate _strip_mdv2 import in
  send_message_tool.py

Other fixes:
- hermes_cli/doctor.py: Replaced undefined logger.debug() with pass
- hermes_cli/config.py: Consolidated chained .endswith() calls

Test results: 3934 passed, 17 failed (all pre-existing on main),
19 skipped. Zero regressions.
2026-04-07 10:25:31 -07:00
Teknium
afe6c63c52 docs: comprehensive docs audit — cover 13 features from last week's PRs (#5815)
Cover documentation gaps found by auditing all 50+ merged PRs from the past week:

tools-reference.md:
- Fix stale tool count (47→46, 11→10 browser tools) after browser_close removal
- Document notify_on_complete parameter in terminal tool description

telegram.md:
- Add Interactive Model Picker section (inline keyboard, provider/model drill-down)

discord.md:
- Add Interactive Model Picker section (Select dropdowns, 120s timeout)
- Add Native Slash Commands for Skills section (auto-registration at startup)

signal.md:
- Expand Attachments section with outgoing media delivery (send_image_file,
  send_voice, send_video, send_document via MEDIA: tags)

webhooks.md:
- Document {__raw__} special template token for full payload access
- Document Forum Topic Delivery via message_thread_id in deliver_extra

slack.md:
- Fix stale/misleading thread reply docs — thread replies no longer require
  @mention when bot has active session (3 locations updated)

security.md:
- Add cross-session isolation (layer 6) and input sanitization (layer 7)
  to security layers overview

feishu.md:
- Add WebSocket Tuning section (ws_reconnect_interval, ws_ping_interval)
- Add Per-Group Access Control section (group_rules with 5 policy types)

credential-pools.md:
- Add Delegation & Subagent Sharing section

delegation.md:
- Update key properties to mention credential pool inheritance

providers.md:
- Add Z.AI Endpoint Auto-Detection note
- Add xAI (Grok) Prompt Caching section

skills-catalog.md:
- Add p5js to creative skills category
2026-04-07 10:21:03 -07:00
Teknium
c58e16757a docs: fix 40+ discrepancies between documentation and codebase (#5818)
Comprehensive audit of all ~100 doc pages against the actual code, fixing:

Reference docs:
- HERMES_API_TIMEOUT default 900 -> 1800 (env-vars)
- TERMINAL_DOCKER_IMAGE default python:3.11 -> nikolaik/python-nodejs (env-vars)
- compression.summary_model default shown as gemini -> actually empty string (env-vars)
- Add missing GOOGLE_API_KEY, GEMINI_API_KEY, GEMINI_BASE_URL env vars (env-vars)
- Add missing /branch (/fork) slash command (slash-commands)
- Fix hermes-cli tool count 39 -> 38 (toolsets-reference)
- Fix hermes-api-server drop list to include text_to_speech (toolsets-reference)
- Fix total tool count 47 -> 48, standalone 14 -> 15 (tools-reference)

User guide:
- web_extract.timeout default 30 -> 360 (configuration)
- Remove display.theme_mode (not implemented in code) (configuration)
- Remove display.background_process_notifications (not in defaults) (configuration)
- Browser inactivity timeout 300/5min -> 120/2min (browser)
- Screenshot path browser_screenshots -> cache/screenshots (browser)
- batch_runner default model claude-sonnet-4-20250514 -> claude-sonnet-4.6
- Add minimax to TTS provider list (voice-mode)
- Remove credential_pool_strategies from auth.json example (credential-pools)
- Fix Slack token path platforms/slack/ -> root ~/.hermes/ (slack)
- Fix Matrix store path for new installs (matrix)
- Fix WhatsApp session path for new installs (whatsapp)
- Fix HomeAssistant config from gateway.json to config.yaml (homeassistant)
- Fix WeCom gateway start command (wecom)

Developer guide:
- Fix tool/toolset counts in architecture overview
- Update line counts: main.py ~5500, setup.py ~3100, run.py ~7500, mcp_tool ~2200
- Replace nonexistent agent/memory_store.py with memory_manager.py + memory_provider.py
- Update _discover_tools() list: remove honcho_tools, add skill_manager_tool
- Add session_search and delegate_task to intercepted tools list (agent-loop)
- Fix budget warning: two-tier system (70% caution, 90% warning) (agent-loop)
- Fix gateway auth order (per-platform first, global last) (gateway-internals)
- Fix email_adapter.py -> email.py, add webhook.py + api_server.py (gateway-internals)
- Add 7 missing providers to provider-runtime list

Other:
- Add Docker --cap-add entries to security doc
- Fix Python version 3.10+ -> 3.11+ (contributing)
- Fix AGENTS.md discovery claim (not hierarchical walk) (tips)
- Fix cron 'add' -> canonical 'create' (cron-internals)
- Add pre_api_request/post_api_request hooks to plugin guide
- Add Google/Gemini provider to providers page
- Clarify OPENAI_BASE_URL deprecation (providers)
2026-04-07 10:17:44 -07:00
Teknium
aa7473cabd feat: replace z-ai/glm-5 with z-ai/glm-5.1 in OpenRouter and Nous model lists 2026-04-07 10:16:24 -07:00
Teknium
caded0a5e7 fix: repair 57 failing CI tests across 14 files (#5823)
* fix: repair 57 failing CI tests across 14 files

Categories of fixes:

**Test isolation under xdist (-n auto):**
- test_hermes_logging: Strip ALL RotatingFileHandlers before each test
  to prevent handlers leaked from other xdist workers from polluting counts
- test_code_execution: Force TERMINAL_ENV=local in setUp — prevents Modal
  AuthError when another test leaks TERMINAL_ENV=modal
- test_timezone: Same TERMINAL_ENV fix for execute_code timezone tests
- test_codex_execution_paths: Mock _resolve_turn_agent_config to ensure
  model resolution works regardless of xdist worker state

**Matrix adapter tests (nio not installed in CI):**
- Add _make_fake_nio() helper with real response classes for isinstance()
  checks in production code
- Replace MagicMock(spec=nio.XxxResponse) with fake_nio instances
- Wrap production method calls with patch.dict('sys.modules', {'nio': ...})
  so import nio succeeds in method bodies
- Use try/except instead of pytest.importorskip for nio.crypto imports
  (importorskip can be fooled by MagicMock in sys.modules)
- test_matrix_voice: Skip entire file if nio is a mock, not just missing

**Stale test expectations:**
- test_cli_provider_resolution: _prompt_provider_choice now takes **kwargs
  (default param added); mock getpass.getpass alongside input
- test_anthropic_oauth_flow: Mock getpass.getpass (code switched from input)
- test_gemini_provider: Mock models.dev + OpenRouter API lookups to test
  hardcoded defaults without external API variance
- test_code_execution: Add notify_on_complete to blocked terminal params
- test_setup_openclaw_migration: Mock prompt_choice to select 'Full setup'
  (new quick-setup path leads to _require_tty → sys.exit in CI)
- test_skill_manager_tool: Patch get_all_skills_dirs alongside SKILLS_DIR
  so _find_skill searches tmp_path, not real ~/.hermes/skills/

**Missing attributes in object.__new__ test runners:**
- test_platform_reconnect: Add session_store to _make_runner()
- test_session_race_guard: Add hooks, _running_agents_ts, session_store,
  delivery_router to _make_runner()

**Production bug fix (gateway/run.py):**
- Fix sentinel eviction race: _AGENT_PENDING_SENTINEL was immediately
  evicted by the stale-detection logic because sentinels have no
  get_activity_summary() method, causing _stale_idle=inf >= timeout.
  Guard _should_evict with 'is not _AGENT_PENDING_SENTINEL'.

* fix: address remaining CI failures

- test_setup_openclaw_migration: Also mock _offer_launch_chat (called at
  end of both quick and full setup paths)
- test_code_execution: Move TERMINAL_ENV=local to module level to protect
  ALL test classes (TestEnvVarFiltering, TestExecuteCodeEdgeCases,
  TestInterruptHandling, TestHeadTailTruncation) from xdist env leaks
- test_matrix: Use try/except for nio.crypto imports (importorskip can be
  fooled by MagicMock in sys.modules under xdist)
2026-04-07 09:58:45 -07:00
Jeffrey Quesnelle
f18a2aa634 Merge pull request #5880 from NousResearch/salvage/5752-nous-free-tier-gating
feat(nous): free-tier model gating and pricing in model selection (salvage #5752)
2026-04-07 12:37:09 -04:00
Teknium
47ddc2bde5 fix(nous): add 3-minute TTL cache to free-tier detection
check_nous_free_tier() now caches its result for 180 seconds to avoid
redundant Portal API calls during a session (auxiliary client init,
model selection, login flow all call it independently).

The TTL is short enough that an account upgrade from free to paid is
reflected within 3 minutes. clear_nous_free_tier_cache() is exposed
for explicit invalidation on login/logout.

Adds 4 tests for cache hit, TTL expiry, explicit clear, and TTL bound.
2026-04-07 09:30:26 -07:00
emozilla
29065cb9b5 feat(nous): free-tier model gating, pricing display, and vision fallback
- Show pricing during initial Nous Portal login (was missing from
  _login_nous, only shown in the already-logged-in hermes model path)

- Filter free models for paid subscribers: non-allowlisted free models
  are hidden; allowlisted models (xiaomi/mimo-v2-pro, xiaomi/mimo-v2-omni)
  only appear when actually priced as free

- Detect free-tier accounts via portal api/oauth/account endpoint
  (monthly_charge == 0); free-tier users see only free models as
  selectable, with paid models shown dimmed and unselectable

- Use xiaomi/mimo-v2-omni as the auxiliary vision model for free-tier
  Nous users so vision_analyze and browser_vision work without paid
  model access (replaces the default google/gemini-3-flash-preview)

- Unavailable models rendered via print() before TerminalMenu to avoid
  simple_term_menu line-width padding artifacts; upgrade URL resolved
  from auth state portal_base_url (supports staging/custom portals)

- Add 21 tests covering filter_nous_free_models, is_nous_free_tier,
  and partition_nous_models_by_tier
2026-04-07 09:21:48 -07:00
SHL0MS
902a02e3d5 Merge pull request #5791 from leotrs/manim-ce-reference-improvements
Expand Manim CE reference docs: geometry, animations, and LaTeX environments
2026-04-07 12:15:59 -04:00
Leo Torres
e8f6854cab docs: expand Manim CE reference docs with additional API coverage
Add geometry mobjects, movement/creation animations, and LaTeX
environments to the skill's reference docs. All verified against
Manim CE v0.20.1.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 11:36:13 +02:00
1059 changed files with 135310 additions and 58788 deletions

View File

@@ -1,2 +0,0 @@
{"created_at_ms":1775533542734,"session_id":"session-1775533542734-0","type":"session_meta","updated_at_ms":1775533542734,"version":1}
{"message":{"blocks":[{"text":"You are Code Claw running as the Gitea user claw-code.\n\nRepository: Timmy_Foundation/hermes-agent\nIssue: #126 — P2: Validate Documentation Audit & Apply to Our Fork\nBranch: claw-code/issue-126\n\nRead the issue and recent comments, then implement the smallest correct change.\nYou are in a git repo checkout already.\n\nIssue body:\n## Context\n\nCommit `43d468ce` is a comprehensive documentation audit — fixes stale info, expands thin pages, adds depth across all docs.\n\n## Acceptance Criteria\n\n- [ ] **Catalog all doc changes**: Run `git show 43d468ce --stat` to list all files changed, then review each for what was fixed/expanded\n- [ ] **Verify key docs are accurate**: Pick 3 docs that were previously thin (setup, deployment, plugin development), confirm they now have comprehensive content\n- [ ] **Identify stale info that was corrected**: Note at least 3 pieces of stale information that were removed or updated\n- [ ] **Apply fixes to our fork if needed**: Check if any of the doc fixes apply to our `Timmy_Foundation/hermes-agent` fork (Timmy-specific references, custom config sections)\n\n## Why This Matters\n\nAccurate documentation is critical for onboarding new agents and maintaining the fleet. Stale docs cost more debugging time than writing them initially.\n\n## Hints\n\n- Run `cd ~/.hermes/hermes-agent && git show 43d468ce --stat` to see the full scope\n- The docs likely cover: setup, plugins, deployment, MCP configuration, and tool integrations\n\n\nParent: #111\n\nRecent comments:\n## 🏷️ Automated Triage Check\n\n**Timestamp:** 2026-04-06T15:30:12.449023 \n**Agent:** Allegro Heartbeat\n\nThis issue has been identified as needing triage:\n\n### Checklist\n- [ ] Clear acceptance criteria defined\n- [ ] Priority label assigned (p0-critical / p1-important / p2-backlog)\n- [ ] Size estimate added (quick-fix / day / week / epic)\n- [ ] Owner assigned\n- [ ] Related issues linked\n\n### Context\n- No comments yet — needs engagement\n- No labels — needs categorization\n- Part of automated backlog maintenance\n\n---\n*Automated triage from Allegro 15-minute heartbeat*\n\n[BURN-DOWN] Dispatched to Code Claw (claw-code worker) as part of nightly burn-down cycle. Heartbeat active.\n\n🟠 Code Claw (OpenRouter qwen/qwen3.6-plus:free) picking up this issue via 15-minute heartbeat.\n\nTimestamp: 2026-04-07T03:45:37Z\n\nRules:\n- Make focused code/config/doc changes only if they directly address the issue.\n- Prefer the smallest proof-oriented fix.\n- Run relevant verification commands if obvious.\n- Do NOT create PRs yourself; the outer worker handles commit/push/PR.\n- If the task is too large or not code-fit, leave the tree unchanged.\n","type":"text"}],"role":"user"},"type":"message"}

View File

@@ -1,2 +0,0 @@
{"created_at_ms":1775534636684,"session_id":"session-1775534636684-0","type":"session_meta","updated_at_ms":1775534636684,"version":1}
{"message":{"blocks":[{"text":"You are Code Claw running as the Gitea user claw-code.\n\nRepository: Timmy_Foundation/hermes-agent\nIssue: #151 — [CONFIG] Add Kimi model to fallback chain for Allegro and Bezalel\nBranch: claw-code/issue-151\n\nRead the issue and recent comments, then implement the smallest correct change.\nYou are in a git repo checkout already.\n\nIssue body:\n## Problem\nAllegro and Bezalel are choking because the Kimi model code is not on their fallback chain. When primary models fail or rate-limit, Kimi should be available as a fallback option but is currently missing.\n\n## Expected Behavior\nKimi model code should be at the front of the fallback chain for both Allegro and Bezalel, so they can remain responsive when primary models are unavailable.\n\n## Context\nThis was reported in Telegram by Alexander Whitestone after observing both agents becoming unresponsive. Ezra was asked to investigate the fallback chain configuration.\n\n## Related\n- timmy-config #302: [ARCH] Fallback Portfolio Runtime Wiring (general fallback framework)\n- hermes-agent #150: [BEZALEL][AUDIT] Telegram Request-to-Gitea Tracking Audit\n\n## Acceptance Criteria\n- [ ] Kimi model code is added to Allegro fallback chain\n- [ ] Kimi model code is added to Bezalel fallback chain\n- [ ] Fallback ordering places Kimi appropriately (front of chain as requested)\n- [ ] Test and confirm both agents can successfully fall back to Kimi\n- [ ] Document the fallback chain configuration for both agents\n\n/assign @ezra\n\nRecent comments:\n[BURN-DOWN] Dispatched to Code Claw (claw-code worker) as part of nightly burn-down cycle. Heartbeat active.\n\n🟠 Code Claw (OpenRouter qwen/qwen3.6-plus:free) picking up this issue via 15-minute heartbeat.\n\nTimestamp: 2026-04-07T04:03:49Z\n\nRules:\n- Make focused code/config/doc changes only if they directly address the issue.\n- Prefer the smallest proof-oriented fix.\n- Run relevant verification commands if obvious.\n- Do NOT create PRs yourself; the outer worker handles commit/push/PR.\n- If the task is too large or not code-fit, leave the tree unchanged.\n","type":"text"}],"role":"user"},"type":"message"}

View File

@@ -1,51 +0,0 @@
# Coverage configuration for hermes-agent
# Run with: pytest --cov=agent --cov=tools --cov=gateway --cov=hermes_cli tests/
[run]
source =
agent
tools
gateway
hermes_cli
acp_adapter
cron
honcho_integration
omit =
*/tests/*
*/test_*
*/__pycache__/*
*/venv/*
*/.venv/*
setup.py
conftest.py
branch = True
[report]
exclude_lines =
pragma: no cover
def __repr__
raise AssertionError
raise NotImplementedError
if __name__ == .__main__.:
if TYPE_CHECKING:
class .*\bProtocol\):
@(abc\.)?abstractmethod
ignore_errors = True
precision = 2
fail_under = 70
show_missing = True
skip_covered = False
[html]
directory = coverage_html
title = Hermes Agent Coverage Report
[xml]
output = coverage.xml

View File

@@ -5,6 +5,7 @@
# Dependencies
node_modules
.venv
# CI/CD
.github

View File

@@ -43,6 +43,15 @@
# KIMI_BASE_URL=https://api.kimi.com/coding/v1 # Default for sk-kimi- keys
# KIMI_BASE_URL=https://api.moonshot.ai/v1 # For legacy Moonshot keys
# KIMI_BASE_URL=https://api.moonshot.cn/v1 # For Moonshot China keys
# KIMI_CN_API_KEY= # Dedicated Moonshot China key
# =============================================================================
# LLM PROVIDER (Arcee AI)
# =============================================================================
# Arcee AI provides access to Trinity models (trinity-mini, trinity-large-*)
# Get an Arcee key at: https://chat.arcee.ai/
# ARCEEAI_API_KEY=
# ARCEE_BASE_URL= # Override default base URL
# =============================================================================
# LLM PROVIDER (MiniMax)
@@ -81,6 +90,23 @@
# HF_TOKEN=
# OPENCODE_GO_BASE_URL=https://opencode.ai/zen/go/v1 # Override default base URL
# =============================================================================
# LLM PROVIDER (Qwen OAuth)
# =============================================================================
# Qwen OAuth reuses your local Qwen CLI login (qwen auth qwen-oauth).
# No API key needed — credentials come from ~/.qwen/oauth_creds.json.
# Optional base URL override:
# HERMES_QWEN_BASE_URL=https://portal.qwen.ai/v1
# =============================================================================
# LLM PROVIDER (Xiaomi MiMo)
# =============================================================================
# Xiaomi MiMo models (mimo-v2-pro, mimo-v2-omni, mimo-v2-flash).
# Get your key at: https://platform.xiaomimimo.com
# XIAOMI_API_KEY=your_key_here
# Optional base URL override:
# XIAOMI_BASE_URL=https://api.xiaomimimo.com/v1
# =============================================================================
# TOOL API KEYS
# =============================================================================
@@ -119,6 +145,10 @@
# Only override here if you need to force a backend without touching config.yaml:
# TERMINAL_ENV=local
# Override the container runtime binary (e.g. to use Podman instead of Docker).
# Useful on systems where Docker's storage driver is broken or unavailable.
# HERMES_DOCKER_BINARY=/usr/local/bin/podman
# Container images (for singularity/docker/modal backends)
# TERMINAL_DOCKER_IMAGE=nikolaik/python-nodejs:python3.11-nodejs20
# TERMINAL_SINGULARITY_IMAGE=docker://nikolaik/python-nodejs:python3.11-nodejs20

2
.gitattributes vendored Normal file
View File

@@ -0,0 +1,2 @@
# Auto-generated files — collapse diffs and exclude from language stats
web/package-lock.json linguist-generated=true

View File

@@ -1,62 +0,0 @@
name: Forge CI
on:
push:
branches: [main]
pull_request:
branches: [main]
concurrency:
group: forge-ci-${{ gitea.ref }}
cancel-in-progress: true
jobs:
smoke-and-build:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
cache-dependency-glob: "uv.lock"
- name: Set up Python 3.11
run: uv python install 3.11
- name: Install package
run: |
uv venv .venv --python 3.11
source .venv/bin/activate
uv pip install -e ".[all,dev]"
- name: Smoke tests
run: |
source .venv/bin/activate
python scripts/smoke_test.py
env:
OPENROUTER_API_KEY: ""
OPENAI_API_KEY: ""
NOUS_API_KEY: ""
- name: Syntax guard
run: |
source .venv/bin/activate
python scripts/syntax_guard.py
- name: No duplicate models
run: |
source .venv/bin/activate
python scripts/check_no_duplicate_models.py
- name: Green-path E2E
run: |
source .venv/bin/activate
python -m pytest tests/test_green_path_e2e.py -q --tb=short
env:
OPENROUTER_API_KEY: ""
OPENAI_API_KEY: ""
NOUS_API_KEY: ""

28
.gitea/workflows/lint.yml Normal file
View File

@@ -0,0 +1,28 @@
name: Lint
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lint:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Check for hardcoded paths
run: python3 scripts/lint_hardcoded_paths.py
continue-on-error: true
- name: Check Python syntax
run: |
find . -name "*.py" -not -path "./.git/*" -not -path "./node_modules/*" | head -100 | xargs python3 -m py_compile || true

View File

@@ -1,44 +0,0 @@
name: Notebook CI
on:
push:
paths:
- 'notebooks/**'
pull_request:
paths:
- 'notebooks/**'
jobs:
notebook-smoke:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install dependencies
run: |
pip install papermill jupytext nbformat
python -m ipykernel install --user --name python3
- name: Execute system health notebook
run: |
papermill notebooks/agent_task_system_health.ipynb /tmp/output.ipynb \
-p threshold 0.5 \
-p hostname ci-runner
- name: Verify output has results
run: |
python -c "
import json
nb = json.load(open('/tmp/output.ipynb'))
code_cells = [c for c in nb['cells'] if c['cell_type'] == 'code']
outputs = [c.get('outputs', []) for c in code_cells]
total_outputs = sum(len(o) for o in outputs)
assert total_outputs > 0, 'Notebook produced no outputs'
print(f'Notebook executed successfully with {total_outputs} output(s)')
"

View File

@@ -1,15 +0,0 @@
#!/bin/bash
#
# Pre-commit hook wrapper for secret leak detection.
#
# Installation:
# git config core.hooksPath .githooks
#
# To bypass temporarily:
# git commit --no-verify
#
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec python3 "${SCRIPT_DIR}/pre-commit.py" "$@"

View File

@@ -0,0 +1,78 @@
#!/usr/bin/env python3
"""
Pre-commit hook: Reject hardcoded home-directory paths.
Install:
cp pre-commit-hardcoded-path.py .git/hooks/pre-commit-hardcoded-path
chmod +x .git/hooks/pre-commit-hardcoded-path
Or add to .pre-commit-config.yaml
"""
import sys
import subprocess
import re
PATTERNS = [
(r"/Users/[\w.\-]+/", "macOS home directory"),
(r"/home/[\w.\-]+/", "Linux home directory"),
(r"(?<![\w/])~/", "unexpanded tilde"),
]
NOQA = re.compile(r"#\s*noqa:?\s*hardcoded-path-ok")
def get_staged_files():
result = subprocess.run(
["git", "diff", "--cached", "--name-only", "--diff-filter=ACM"],
capture_output=True, text=True
)
return [f for f in result.stdout.strip().split("\n") if f.endswith(".py")]
def check_file(filepath):
try:
result = subprocess.run(
["git", "show", f":{filepath}"],
capture_output=True, text=True
)
content = result.stdout
except Exception:
return []
violations = []
for i, line in enumerate(content.split("\n"), 1):
if line.strip().startswith("#"):
continue
if line.strip().startswith(("import ", "from ")):
continue
if NOQA.search(line):
continue
for pattern, desc in PATTERNS:
if re.search(pattern, line):
violations.append((filepath, i, line.strip(), desc))
break
return violations
def main():
files = get_staged_files()
if not files:
sys.exit(0)
all_violations = []
for f in files:
all_violations.extend(check_file(f))
if all_violations:
print("ERROR: Hardcoded home directory paths detected:")
print()
for filepath, line_no, line, desc in all_violations:
print(f" {filepath}:{line_no}: {desc}")
print(f" {line[:100]}")
print()
print("Fix: Use $HOME, relative paths, or get_hermes_home().")
print("Override: Add '# noqa: hardcoded-path-ok' to the line.")
sys.exit(1)
sys.exit(0)
if __name__ == "__main__":
main()

View File

@@ -1,327 +0,0 @@
#!/usr/bin/env python3
"""
Pre-commit hook for detecting secret leaks in staged files.
Scans staged diffs and full file contents for common secret patterns,
token file paths, private keys, and credential strings.
Installation:
git config core.hooksPath .githooks
To bypass:
git commit --no-verify
"""
from __future__ import annotations
import re
import subprocess
import sys
from pathlib import Path
from typing import Iterable, List, Callable, Union
# ANSI color codes
RED = "\033[0;31m"
YELLOW = "\033[1;33m"
GREEN = "\033[0;32m"
NC = "\033[0m"
class Finding:
"""Represents a single secret leak finding."""
def __init__(self, filename: str, line: int, message: str) -> None:
self.filename = filename
self.line = line
self.message = message
def __repr__(self) -> str:
return f"Finding({self.filename!r}, {self.line}, {self.message!r})"
def __eq__(self, other: object) -> bool:
if not isinstance(other, Finding):
return NotImplemented
return (
self.filename == other.filename
and self.line == other.line
and self.message == other.message
)
# ---------------------------------------------------------------------------
# Regex patterns
# ---------------------------------------------------------------------------
_RE_SK_KEY = re.compile(r"sk-[a-zA-Z0-9]{20,}")
_RE_BEARER = re.compile(r"Bearer\s+[a-zA-Z0-9_-]{20,}")
_RE_ENV_ASSIGN = re.compile(
r"^(?:export\s+)?"
r"(OPENAI_API_KEY|GITEA_TOKEN|ANTHROPIC_API_KEY|KIMI_API_KEY"
r"|TELEGRAM_BOT_TOKEN|DISCORD_TOKEN)"
r"\s*=\s*(.+)$"
)
_RE_TOKEN_PATHS = re.compile(
r'(?:^|["\'\s])'
r"(\.(?:env)"
r"|(?:secrets|keystore|credentials|token|api_keys)\.json"
r"|~/\.hermes/credentials/"
r"|/root/nostr-relay/keystore\.json)"
)
_RE_PRIVATE_KEY = re.compile(
r"-----BEGIN (PRIVATE KEY|RSA PRIVATE KEY|OPENSSH PRIVATE KEY)-----"
)
_RE_URL_PASSWORD = re.compile(r"https?://[^:]+:[^@]+@")
_RE_RAW_TOKEN = re.compile(r'"token"\s*:\s*"([^"]{10,})"')
_RE_RAW_API_KEY = re.compile(r'"api_key"\s*:\s*"([^"]{10,})"')
# Safe patterns (placeholders)
_SAFE_ENV_VALUES = {
"<YOUR_API_KEY>",
"***",
"REDACTED",
"",
}
_RE_DOC_EXAMPLE = re.compile(
r"\b(?:example|documentation|doc|readme)\b",
re.IGNORECASE,
)
_RE_OS_ENVIRON = re.compile(r"os\.environ(?:\.get|\[)")
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def is_binary_content(content: Union[str, bytes]) -> bool:
"""Return True if content appears to be binary."""
if isinstance(content, str):
return False
return b"\x00" in content
def _looks_like_safe_env_line(line: str) -> bool:
"""Check if a line is a safe env var read or reference."""
if _RE_OS_ENVIRON.search(line):
return True
# Variable expansion like $OPENAI_API_KEY
if re.search(r'\$\w+\s*$', line.strip()):
return True
return False
def _is_placeholder(value: str) -> bool:
"""Check if a value is a known placeholder or empty."""
stripped = value.strip().strip('"').strip("'")
if stripped in _SAFE_ENV_VALUES:
return True
# Single word references like $VAR
if re.fullmatch(r"\$\w+", stripped):
return True
return False
def _is_doc_or_example(line: str, value: str | None = None) -> bool:
"""Check if line appears to be documentation or example code."""
# If the line contains a placeholder value, it's likely documentation
if value is not None and _is_placeholder(value):
return True
# If the line contains doc keywords and no actual secret-looking value
if _RE_DOC_EXAMPLE.search(line):
# For env assignments, if value is empty or placeholder
m = _RE_ENV_ASSIGN.search(line)
if m and _is_placeholder(m.group(2)):
return True
return False
# ---------------------------------------------------------------------------
# Scanning
# ---------------------------------------------------------------------------
def scan_line(line: str, filename: str, line_no: int) -> Iterable[Finding]:
"""Scan a single line for secret leak patterns."""
stripped = line.rstrip("\n")
if not stripped:
return
# --- API keys ----------------------------------------------------------
if _RE_SK_KEY.search(stripped):
yield Finding(filename, line_no, "Potential API key (sk-...) found")
return # One finding per line is enough
if _RE_BEARER.search(stripped):
yield Finding(filename, line_no, "Potential Bearer token found")
return
# --- Env var assignments -----------------------------------------------
m = _RE_ENV_ASSIGN.search(stripped)
if m:
var_name = m.group(1)
value = m.group(2)
if _looks_like_safe_env_line(stripped):
return
if _is_doc_or_example(stripped, value):
return
if not _is_placeholder(value):
yield Finding(
filename,
line_no,
f"Potential secret assignment: {var_name}=...",
)
return
# --- Token file paths --------------------------------------------------
if _RE_TOKEN_PATHS.search(stripped):
yield Finding(filename, line_no, "Potential token file path found")
return
# --- Private key blocks ------------------------------------------------
if _RE_PRIVATE_KEY.search(stripped):
yield Finding(filename, line_no, "Private key block found")
return
# --- Passwords in URLs -------------------------------------------------
if _RE_URL_PASSWORD.search(stripped):
yield Finding(filename, line_no, "Password in URL found")
return
# --- Raw token patterns ------------------------------------------------
if _RE_RAW_TOKEN.search(stripped):
yield Finding(filename, line_no, 'Raw "token" string with long value')
return
if _RE_RAW_API_KEY.search(stripped):
yield Finding(filename, line_no, 'Raw "api_key" string with long value')
return
def scan_content(content: Union[str, bytes], filename: str) -> List[Finding]:
"""Scan full file content for secrets."""
if isinstance(content, bytes):
try:
text = content.decode("utf-8")
except UnicodeDecodeError:
return []
else:
text = content
findings: List[Finding] = []
for line_no, line in enumerate(text.splitlines(), start=1):
findings.extend(scan_line(line, filename, line_no))
return findings
def scan_files(
files: List[str],
content_reader: Callable[[str], bytes],
) -> List[Finding]:
"""Scan a list of files using the provided content reader."""
findings: List[Finding] = []
for filepath in files:
content = content_reader(filepath)
if is_binary_content(content):
continue
findings.extend(scan_content(content, filepath))
return findings
# ---------------------------------------------------------------------------
# Git helpers
# ---------------------------------------------------------------------------
def get_staged_files() -> List[str]:
"""Return a list of staged file paths (excluding deletions)."""
result = subprocess.run(
["git", "diff", "--cached", "--name-only", "--diff-filter=ACMR"],
capture_output=True,
text=True,
)
if result.returncode != 0:
return []
return [f for f in result.stdout.strip().split("\n") if f]
def get_staged_diff() -> str:
"""Return the diff of staged changes."""
result = subprocess.run(
["git", "diff", "--cached", "--no-color", "-U0"],
capture_output=True,
text=True,
)
if result.returncode != 0:
return ""
return result.stdout
def get_file_content_at_staged(filepath: str) -> bytes:
"""Return the staged content of a file."""
result = subprocess.run(
["git", "show", f":{filepath}"],
capture_output=True,
)
if result.returncode != 0:
return b""
return result.stdout
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main() -> int:
print(f"{GREEN}🔍 Scanning for secret leaks in staged files...{NC}")
staged_files = get_staged_files()
if not staged_files:
print(f"{GREEN}✓ No files staged for commit{NC}")
return 0
# Scan both full staged file contents and the diff content
findings = scan_files(staged_files, get_file_content_at_staged)
diff_text = get_staged_diff()
if diff_text:
for line_no, line in enumerate(diff_text.splitlines(), start=1):
# Only scan added lines in the diff
if line.startswith("+") and not line.startswith("+++"):
findings.extend(scan_line(line[1:], "<diff>", line_no))
if not findings:
print(f"{GREEN}✓ No potential secret leaks detected{NC}")
return 0
print(f"{RED}✗ Potential secret leaks detected:{NC}\n")
for finding in findings:
loc = finding.filename
print(
f" {RED}[LEAK]{NC} {loc}:{finding.line}{finding.message}"
)
print()
print(f"{RED}╔════════════════════════════════════════════════════════════╗{NC}")
print(f"{RED}║ COMMIT BLOCKED: Potential secrets detected! ║{NC}")
print(f"{RED}╚════════════════════════════════════════════════════════════╝{NC}")
print()
print("Recommendations:")
print(" 1. Remove secrets from your code")
print(" 2. Use environment variables or a secrets manager")
print(" 3. Add sensitive files to .gitignore")
print(" 4. Rotate any exposed credentials immediately")
print()
print("If you are CERTAIN this is a false positive, you can bypass:")
print(" git commit --no-verify")
print()
return 1
if __name__ == "__main__":
sys.exit(main())

13
.github/CODEOWNERS vendored
View File

@@ -1,13 +0,0 @@
# Default owners for all files
* @Timmy
# Critical paths require explicit review
/gateway/ @Timmy
/tools/ @Timmy
/agent/ @Timmy
/config/ @Timmy
/scripts/ @Timmy
/.github/workflows/ @Timmy
/pyproject.toml @Timmy
/requirements.txt @Timmy
/Dockerfile @Timmy

View File

@@ -11,6 +11,7 @@ body:
**Before submitting**, please:
- [ ] Search [existing issues](https://github.com/NousResearch/hermes-agent/issues) to avoid duplicates
- [ ] Update to the latest version (`hermes update`) and confirm the bug still exists
- [ ] Run `hermes debug share` and paste the links below (see Debug Report section)
- type: textarea
id: description
@@ -82,6 +83,25 @@ body:
- Slack
- WhatsApp
- type: textarea
id: debug-report
attributes:
label: Debug Report
description: |
Run `hermes debug share` from your terminal and paste the links it prints here.
This uploads your system info, config, and recent logs to a paste service automatically.
If you're in an interactive chat session, you can also use the `/debug` slash command — it does the same thing.
If the upload fails, run `hermes debug share --local` and paste the output directly.
placeholder: |
Report https://paste.rs/abc123
agent.log https://paste.rs/def456
gateway.log https://paste.rs/ghi789
render: shell
validations:
required: true
- type: input
id: os
attributes:
@@ -97,8 +117,6 @@ body:
label: Python Version
description: Output of `python --version`
placeholder: "3.11.9"
validations:
required: true
- type: input
id: hermes-version
@@ -106,14 +124,14 @@ body:
label: Hermes Version
description: Output of `hermes version`
placeholder: "2.1.0"
validations:
required: true
- type: textarea
id: logs
attributes:
label: Relevant Logs / Traceback
description: Paste any error output, traceback, or log messages. This will be auto-formatted as code.
label: Additional Logs / Traceback (optional)
description: |
The debug report above covers most logs. Use this field for any extra error output,
tracebacks, or screenshots not captured by `hermes debug share`.
render: shell
- type: textarea

View File

@@ -71,3 +71,15 @@ body:
label: Contribution
options:
- label: I'd like to implement this myself and submit a PR
- type: textarea
id: debug-report
attributes:
label: Debug Report (optional)
description: |
If this feature request is related to a problem you're experiencing, run `hermes debug share` and paste the links here.
In an interactive chat session, you can use `/debug` instead.
This helps us understand your environment and any related logs.
placeholder: |
Report https://paste.rs/abc123
render: shell

View File

@@ -1,99 +0,0 @@
name: "🔒 Security PR Checklist"
description: "Use this when your PR touches authentication, file I/O, external API calls, or other sensitive paths."
title: "[Security Review]: "
labels: ["security", "needs-review"]
body:
- type: markdown
attributes:
value: |
## Security Pre-Merge Review
Complete this checklist before requesting review on PRs that touch **authentication, file I/O, external API calls, or secrets handling**.
- type: input
id: pr-link
attributes:
label: Pull Request
description: Link to the PR being reviewed
placeholder: "https://forge.alexanderwhitestone.com/Timmy_Foundation/hermes-agent/pulls/XXX"
validations:
required: true
- type: dropdown
id: change-type
attributes:
label: Change Category
description: What kind of sensitive change does this PR make?
multiple: true
options:
- Authentication / Authorization
- File I/O (read/write/delete)
- External API calls (outbound HTTP/network)
- Secret / credential handling
- Command execution (subprocess/shell)
- Dependency addition or update
- Configuration changes
- CI/CD pipeline changes
validations:
required: true
- type: checkboxes
id: secrets-checklist
attributes:
label: Secrets & Credentials
options:
- label: No secrets, API keys, or credentials are hardcoded
required: true
- label: All sensitive values are loaded from environment variables or a secrets manager
required: true
- label: Test fixtures use fake/placeholder values, not real credentials
required: true
- type: checkboxes
id: input-validation-checklist
attributes:
label: Input Validation
options:
- label: All external input (user, API, file) is validated before use
required: true
- label: File paths are validated against path traversal (`../`, null bytes, absolute paths)
- label: URLs are validated for SSRF (blocked private/metadata IPs)
- label: Shell commands do not use `shell=True` with user-controlled input
- type: checkboxes
id: auth-checklist
attributes:
label: Authentication & Authorization (if applicable)
options:
- label: Authentication tokens are not logged or exposed in error messages
- label: Authorization checks happen server-side, not just client-side
- label: Session tokens are properly scoped and have expiry
- type: checkboxes
id: supply-chain-checklist
attributes:
label: Supply Chain
options:
- label: New dependencies are pinned to a specific version range
- label: Dependencies come from trusted sources (PyPI, npm, official repos)
- label: No `.pth` files or install hooks that execute arbitrary code
- label: "`pip-audit` passes (no known CVEs in added dependencies)"
- type: textarea
id: threat-model
attributes:
label: Threat Model Notes
description: |
Briefly describe the attack surface this change introduces or modifies, and how it is mitigated.
placeholder: |
This PR adds a new outbound HTTP call to the OpenRouter API.
Mitigation: URL is hardcoded (no user input), response is parsed with strict schema validation.
- type: textarea
id: testing
attributes:
label: Security Testing Done
description: What security testing did you perform?
placeholder: |
- Ran validate_security.py — all checks pass
- Tested path traversal attempts manually
- Verified no secrets in git diff

View File

@@ -9,7 +9,8 @@ body:
Sorry you're having trouble! Please fill out the details below so we can help.
**Quick checks first:**
- Run `hermes doctor` and include the output below
- Run `hermes debug share` and paste the links in the Debug Report section below
- If you're in a chat session, you can use `/debug` instead — it does the same thing
- Try `hermes update` to get the latest version
- Check the [README troubleshooting section](https://github.com/NousResearch/hermes-agent#troubleshooting)
- For general questions, consider the [Nous Research Discord](https://discord.gg/NousResearch) for faster help
@@ -74,10 +75,21 @@ body:
placeholder: "2.1.0"
- type: textarea
id: doctor-output
id: debug-report
attributes:
label: Output of `hermes doctor`
description: Run `hermes doctor` and paste the full output. This will be auto-formatted.
label: Debug Report
description: |
Run `hermes debug share` from your terminal and paste the links it prints here.
This uploads your system info, config, and recent logs to a paste service automatically.
If you're in an interactive chat session, you can also use the `/debug` slash command — it does the same thing.
If the upload fails or install didn't get that far, run `hermes debug share --local` and paste the output directly.
If even that doesn't work, run `hermes doctor` and paste that output instead.
placeholder: |
Report https://paste.rs/abc123
agent.log https://paste.rs/def456
gateway.log https://paste.rs/ghi789
render: shell
- type: textarea

73
.github/workflows/contributor-check.yml vendored Normal file
View File

@@ -0,0 +1,73 @@
name: Contributor Attribution Check
on:
pull_request:
branches: [main]
paths:
# Only run when code files change (not docs-only PRs)
- '*.py'
- '**/*.py'
- '.github/workflows/contributor-check.yml'
permissions:
contents: read
jobs:
check-attribution:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0 # Full history needed for git log
- name: Check for unmapped contributor emails
run: |
# Get the merge base between this PR and main
MERGE_BASE=$(git merge-base origin/main HEAD)
# Find any new author emails in this PR's commits
NEW_EMAILS=$(git log ${MERGE_BASE}..HEAD --format='%ae' --no-merges | sort -u)
if [ -z "$NEW_EMAILS" ]; then
echo "No new commits to check."
exit 0
fi
# Check each email against AUTHOR_MAP in release.py
MISSING=""
while IFS= read -r email; do
# Skip teknium and bot emails
case "$email" in
*teknium*|*noreply@github.com*|*dependabot*|*github-actions*|*anthropic.com*|*cursor.com*)
continue ;;
esac
# Check if email is in AUTHOR_MAP (either as a key or matches noreply pattern)
if echo "$email" | grep -qP '\+.*@users\.noreply\.github\.com'; then
continue # GitHub noreply emails auto-resolve
fi
if ! grep -qF "\"${email}\"" scripts/release.py 2>/dev/null; then
AUTHOR=$(git log --author="$email" --format='%an' -1)
MISSING="${MISSING}\n ${email} (${AUTHOR})"
fi
done <<< "$NEW_EMAILS"
if [ -n "$MISSING" ]; then
echo ""
echo "⚠️ New contributor email(s) not in AUTHOR_MAP:"
echo -e "$MISSING"
echo ""
echo "Please add mappings to scripts/release.py AUTHOR_MAP:"
echo -e "$MISSING" | while read -r line; do
email=$(echo "$line" | sed 's/^ *//' | cut -d' ' -f1)
[ -z "$email" ] && continue
echo " \"${email}\": \"<github-username>\","
done
echo ""
echo "To find the GitHub username for an email:"
echo " gh api 'search/users?q=EMAIL+in:email' --jq '.items[0].login'"
exit 1
else
echo "✅ All contributor emails are mapped in AUTHOR_MAP."
fi

View File

@@ -1,83 +0,0 @@
name: Dependency Audit
on:
pull_request:
branches: [main]
paths:
- 'requirements.txt'
- 'pyproject.toml'
- 'uv.lock'
schedule:
- cron: '0 8 * * 1' # Weekly on Monday
workflow_dispatch:
permissions:
pull-requests: write
contents: read
jobs:
audit:
name: Audit Python dependencies
runs-on: ubuntu-latest
container: catthehacker/ubuntu:act-22.04
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v5
- name: Set up Python
run: uv python install 3.11
- name: Install pip-audit
run: uv pip install --system pip-audit
- name: Run pip-audit
id: audit
run: |
set -euo pipefail
# Run pip-audit against the lock file/requirements
if pip-audit --requirement requirements.txt -f json -o /tmp/audit-results.json 2>/tmp/audit-stderr.txt; then
echo "found=false" >> "$GITHUB_OUTPUT"
else
echo "found=true" >> "$GITHUB_OUTPUT"
# Check severity
CRITICAL=$(python3 -c "
import json, sys
data = json.load(open('/tmp/audit-results.json'))
vulns = data.get('dependencies', [])
for d in vulns:
for v in d.get('vulns', []):
aliases = v.get('aliases', [])
# Check for critical/high CVSS
if any('CVSS' in str(a) for a in aliases):
print('true')
sys.exit(0)
print('false')
" 2>/dev/null || echo 'false')
echo "critical=${CRITICAL}" >> "$GITHUB_OUTPUT"
fi
continue-on-error: true
- name: Post results comment
if: steps.audit.outputs.found == 'true' && github.event_name == 'pull_request'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
BODY="## ⚠️ Dependency Vulnerabilities Detected
\`pip-audit\` found vulnerable dependencies in this PR. Review and update before merging.
\`\`\`
$(cat /tmp/audit-results.json | python3 -c "
import json, sys
data = json.load(sys.stdin)
for dep in data.get('dependencies', []):
for v in dep.get('vulns', []):
print(f\" {dep['name']}=={dep['version']}: {v['id']} - {v.get('description', '')[:120]}\")
" 2>/dev/null || cat /tmp/audit-stderr.txt)
\`\`\`
---
*Automated scan by [dependency-audit](/.github/workflows/dependency-audit.yml)*"
gh pr comment "${{ github.event.pull_request.number }}" --body "$BODY"
- name: Fail on vulnerabilities
if: steps.audit.outputs.found == 'true'
run: |
echo "::error::Vulnerable dependencies detected. See PR comment for details."
cat /tmp/audit-results.json | python3 -m json.tool || true
exit 1

View File

@@ -28,24 +28,32 @@ jobs:
name: github-pages
url: ${{ steps.deploy.outputs.page_url }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-node@v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 20
cache: npm
cache-dependency-path: website/package-lock.json
- uses: actions/setup-python@v5
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: '3.11'
- name: Install PyYAML for skill extraction
run: pip install pyyaml
run: pip install pyyaml==6.0.2 httpx==0.28.1
- name: Extract skill metadata for dashboard
run: python3 website/scripts/extract-skills.py
- name: Build skills index (if not already present)
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if [ ! -f website/static/api/skills-index.json ]; then
python3 scripts/build_skills_index.py || echo "Skills index build failed (non-fatal)"
fi
- name: Install dependencies
run: npm ci
working-directory: website
@@ -65,10 +73,10 @@ jobs:
echo "hermes-agent.nousresearch.com" > _site/CNAME
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3
with:
path: _site
- name: Deploy to GitHub Pages
id: deploy
uses: actions/deploy-pages@v4
uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4

View File

@@ -8,6 +8,9 @@ on:
release:
types: [published]
permissions:
contents: read
concurrency:
group: docker-${{ github.ref }}
cancel-in-progress: true
@@ -17,22 +20,29 @@ jobs:
# Only run on the upstream repository, not on forks
if: github.repository == 'NousResearch/hermes-agent'
runs-on: ubuntu-latest
timeout-minutes: 30
timeout-minutes: 60
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
submodules: recursive
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3
- name: Build image
uses: docker/build-push-action@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
# Build amd64 only so we can `load` the image for smoke testing.
# `load: true` cannot export a multi-arch manifest to the local daemon.
# The multi-arch build follows on push to main / release.
- name: Build image (amd64, smoke test)
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
with:
context: .
file: Dockerfile
load: true
platforms: linux/amd64
tags: nousresearch/hermes-agent:test
cache-from: type=gha
cache-to: type=gha,mode=max
@@ -46,34 +56,31 @@ jobs:
- name: Log in to Docker Hub
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
uses: docker/login-action@v3
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Push image (main branch)
- name: Push multi-arch image (main branch)
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: docker/build-push-action@v6
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
with:
context: .
file: Dockerfile
push: true
tags: |
nousresearch/hermes-agent:latest
nousresearch/hermes-agent:${{ github.sha }}
platforms: linux/amd64,linux/arm64
tags: nousresearch/hermes-agent:latest
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Push image (release)
- name: Push multi-arch image (release)
if: github.event_name == 'release'
uses: docker/build-push-action@v6
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
with:
context: .
file: Dockerfile
push: true
tags: |
nousresearch/hermes-agent:latest
nousresearch/hermes-agent:${{ github.event.release.tag_name }}
nousresearch/hermes-agent:${{ github.sha }}
platforms: linux/amd64,linux/arm64
tags: nousresearch/hermes-agent:${{ github.event.release.tag_name }}
cache-from: type=gha
cache-to: type=gha,mode=max

View File

@@ -7,14 +7,16 @@ on:
- '.github/workflows/docs-site-checks.yml'
workflow_dispatch:
permissions:
contents: read
jobs:
docs-site-checks:
runs-on: ubuntu-latest
container: catthehacker/ubuntu:act-22.04
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-node@v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 20
cache: npm
@@ -24,12 +26,12 @@ jobs:
run: npm ci
working-directory: website
- uses: actions/setup-python@v5
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: '3.11'
- name: Install Python dependencies
run: python -m pip install ascii-guard pyyaml
- name: Install ascii-guard
run: python -m pip install ascii-guard==2.3.0 pyyaml==6.0.3
- name: Extract skill metadata for dashboard
run: python3 website/scripts/extract-skills.py

View File

@@ -14,6 +14,9 @@ on:
- 'run_agent.py'
- 'acp_adapter/**'
permissions:
contents: read
concurrency:
group: nix-${{ github.ref }}
cancel-in-progress: true
@@ -26,9 +29,9 @@ jobs:
runs-on: ${{ matrix.os }}
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: DeterminateSystems/nix-installer-action@main
- uses: DeterminateSystems/magic-nix-cache-action@main
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: DeterminateSystems/nix-installer-action@ef8a148080ab6020fd15196c2084a2eea5ff2d25 # v22
- uses: DeterminateSystems/magic-nix-cache-action@565684385bcd71bad329742eefe8d12f2e765b39 # v13
- name: Check flake
if: runner.os == 'Linux'
run: nix flake check --print-build-logs

View File

@@ -1,115 +0,0 @@
name: Quarterly Security Audit
on:
schedule:
# Run at 08:00 UTC on the first day of each quarter (Jan, Apr, Jul, Oct)
- cron: '0 8 1 1,4,7,10 *'
workflow_dispatch:
inputs:
reason:
description: 'Reason for manual trigger'
required: false
default: 'Manual quarterly audit'
permissions:
issues: write
contents: read
jobs:
create-audit-issue:
name: Create quarterly security audit issue
runs-on: ubuntu-latest
container: catthehacker/ubuntu:act-22.04
steps:
- uses: actions/checkout@v4
- name: Get quarter info
id: quarter
run: |
MONTH=$(date +%-m)
YEAR=$(date +%Y)
QUARTER=$(( (MONTH - 1) / 3 + 1 ))
echo "quarter=Q${QUARTER}-${YEAR}" >> "$GITHUB_OUTPUT"
echo "year=${YEAR}" >> "$GITHUB_OUTPUT"
echo "q=${QUARTER}" >> "$GITHUB_OUTPUT"
- name: Create audit issue
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
QUARTER="${{ steps.quarter.outputs.quarter }}"
gh issue create \
--title "[$QUARTER] Quarterly Security Audit" \
--label "security,audit" \
--body "$(cat <<'BODY'
## Quarterly Security Audit — ${{ steps.quarter.outputs.quarter }}
This is the scheduled quarterly security audit for the hermes-agent project. Complete each section and close this issue when the audit is done.
**Audit Period:** ${{ steps.quarter.outputs.quarter }}
**Due:** End of quarter
**Owner:** Assign to a maintainer
---
## 1. Open Issues & PRs Audit
Review all open issues and PRs for security-relevant content. Tag any that touch attack surfaces with the `security` label.
- [ ] Review open issues older than 30 days for unaddressed security concerns
- [ ] Tag security-relevant open PRs with `needs-security-review`
- [ ] Check for any issues referencing CVEs or known vulnerabilities
- [ ] Review recently closed security issues — are fixes deployed?
## 2. Dependency Audit
- [ ] Run `pip-audit` against current `requirements.txt` / `pyproject.toml`
- [ ] Check `uv.lock` for any pinned versions with known CVEs
- [ ] Review any `git+` dependencies for recent changes or compromise signals
- [ ] Update vulnerable dependencies and open PRs for each
## 3. Critical Path Review
Review recent changes to attack-surface paths:
- [ ] `gateway/` — authentication, message routing, platform adapters
- [ ] `tools/` — file I/O, command execution, web access
- [ ] `agent/` — prompt handling, context management
- [ ] `config/` — secrets loading, configuration parsing
- [ ] `.github/workflows/` — CI/CD integrity
Run: `git log --since="3 months ago" --name-only -- gateway/ tools/ agent/ config/ .github/workflows/`
## 4. Secret Scan
- [ ] Run secret scanner on the full codebase (not just diffs)
- [ ] Verify no credentials are present in git history
- [ ] Confirm all API keys/tokens in use are rotated on a regular schedule
## 5. Access & Permissions Review
- [ ] Review who has write access to the main branch
- [ ] Confirm branch protection rules are still in place (require PR + review)
- [ ] Verify CI/CD secrets are scoped correctly (not over-permissioned)
- [ ] Review CODEOWNERS file for accuracy
## 6. Vulnerability Triage
List any new vulnerabilities found this quarter:
| ID | Component | Severity | Status | Owner |
|----|-----------|----------|--------|-------|
| | | | | |
## 7. Action Items
| Action | Owner | Due Date | Status |
|--------|-------|----------|--------|
| | | | |
---
*Auto-generated by [quarterly-security-audit](/.github/workflows/quarterly-security-audit.yml). Close this issue when the audit is complete.*
BODY
)"

View File

@@ -1,137 +0,0 @@
name: Secret Scan
on:
pull_request:
types: [opened, synchronize, reopened]
permissions:
pull-requests: write
contents: read
jobs:
scan:
name: Scan for secrets
runs-on: ubuntu-latest
container: catthehacker/ubuntu:act-22.04
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Fetch base branch
run: git fetch origin ${{ github.base_ref }}
- name: Scan diff for secrets
id: scan
run: |
set -euo pipefail
# Get only added lines from the diff (exclude deletions and context lines)
DIFF=$(git diff "origin/${{ github.base_ref }}"...HEAD -- \
':!*.lock' ':!uv.lock' ':!package-lock.json' ':!yarn.lock' \
| grep '^+' | grep -v '^+++' || true)
FINDINGS=""
CRITICAL=false
check() {
local label="$1"
local pattern="$2"
local critical="${3:-false}"
local matches
matches=$(echo "$DIFF" | grep -oP "$pattern" || true)
if [ -n "$matches" ]; then
FINDINGS="${FINDINGS}\n- **${label}**: pattern matched"
if [ "$critical" = "true" ]; then
CRITICAL=true
fi
fi
}
# AWS keys — critical
check "AWS Access Key" 'AKIA[0-9A-Z]{16}' true
# Private key headers — critical
check "Private Key Header" '-----BEGIN (RSA|EC|DSA|OPENSSH|PGP) PRIVATE KEY' true
# OpenAI / Anthropic style keys
check "OpenAI-style API key (sk-)" 'sk-[a-zA-Z0-9]{20,}' false
# GitHub tokens
check "GitHub personal access token (ghp_)" 'ghp_[a-zA-Z0-9]{36}' true
check "GitHub fine-grained PAT (github_pat_)" 'github_pat_[a-zA-Z0-9_]{1,}' true
# Slack tokens
check "Slack bot token (xoxb-)" 'xoxb-[0-9A-Za-z\-]{10,}' true
check "Slack user token (xoxp-)" 'xoxp-[0-9A-Za-z\-]{10,}' true
# Generic assignment patterns — exclude obvious placeholders
GENERIC=$(echo "$DIFF" | grep -iP '(api_key|apikey|api-key|secret_key|access_token|auth_token)\s*[=:]\s*['"'"'"][^'"'"'"]{20,}['"'"'"]' \
| grep -ivP '(fake|mock|test|placeholder|example|dummy|your[_-]|xxx|<|>|\{\{)' || true)
if [ -n "$GENERIC" ]; then
FINDINGS="${FINDINGS}\n- **Generic credential assignment**: possible hardcoded secret"
fi
# .env additions with long values
ENV_DIFF=$(git diff "origin/${{ github.base_ref }}"...HEAD -- '*.env' '**/.env' '.env*' \
| grep '^+' | grep -v '^+++' || true)
ENV_MATCHES=$(echo "$ENV_DIFF" | grep -P '^[A-Z_]+=.{16,}' \
| grep -ivP '(fake|mock|test|placeholder|example|dummy|your[_-]|xxx)' || true)
if [ -n "$ENV_MATCHES" ]; then
FINDINGS="${FINDINGS}\n- **.env file**: lines with potentially real secret values detected"
fi
# Write outputs
if [ -n "$FINDINGS" ]; then
echo "found=true" >> "$GITHUB_OUTPUT"
else
echo "found=false" >> "$GITHUB_OUTPUT"
fi
if [ "$CRITICAL" = "true" ]; then
echo "critical=true" >> "$GITHUB_OUTPUT"
else
echo "critical=false" >> "$GITHUB_OUTPUT"
fi
# Store findings in a file to use in comment step
printf "%b" "$FINDINGS" > /tmp/secret-findings.txt
- name: Post PR comment with findings
if: steps.scan.outputs.found == 'true' && github.event_name == 'pull_request'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
FINDINGS=$(cat /tmp/secret-findings.txt)
SEVERITY="warning"
if [ "${{ steps.scan.outputs.critical }}" = "true" ]; then
SEVERITY="CRITICAL"
fi
BODY="## Secret Scan — ${SEVERITY} findings
The automated secret scanner detected potential secrets in the diff for this PR.
### Findings
${FINDINGS}
### What to do
1. Remove any real credentials from the diff immediately.
2. If the match is a false positive (test fixture, placeholder), add a comment explaining why or rename the variable to include \`fake\`, \`mock\`, or \`test\`.
3. Rotate any exposed credentials regardless of whether this PR is merged.
---
*Automated scan by [secret-scan](/.github/workflows/secret-scan.yml)*"
gh pr comment "${{ github.event.pull_request.number }}" --body "$BODY"
- name: Fail on critical secrets
if: steps.scan.outputs.critical == 'true'
run: |
echo "::error::Critical secrets detected in diff (private keys, AWS keys, or GitHub tokens). Remove them before merging."
exit 1
- name: Warn on non-critical findings
if: steps.scan.outputs.found == 'true' && steps.scan.outputs.critical == 'false'
run: |
echo "::warning::Potential secrets detected in diff. Review the PR comment for details."

101
.github/workflows/skills-index.yml vendored Normal file
View File

@@ -0,0 +1,101 @@
name: Build Skills Index
on:
schedule:
# Run twice daily: 6 AM and 6 PM UTC
- cron: '0 6,18 * * *'
workflow_dispatch: # Manual trigger
push:
branches: [main]
paths:
- 'scripts/build_skills_index.py'
- '.github/workflows/skills-index.yml'
permissions:
contents: read
jobs:
build-index:
# Only run on the upstream repository, not on forks
if: github.repository == 'NousResearch/hermes-agent'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: '3.11'
- name: Install dependencies
run: pip install httpx==0.28.1 pyyaml==6.0.2
- name: Build skills index
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: python scripts/build_skills_index.py
- name: Upload index artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: skills-index
path: website/static/api/skills-index.json
retention-days: 7
deploy-with-index:
needs: build-index
runs-on: ubuntu-latest
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deploy.outputs.page_url }}
# Only deploy on schedule or manual trigger (not on every push to the script)
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: skills-index
path: website/static/api/
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 20
cache: npm
cache-dependency-path: website/package-lock.json
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: '3.11'
- name: Install PyYAML for skill extraction
run: pip install pyyaml==6.0.2
- name: Extract skill metadata for dashboard
run: python3 website/scripts/extract-skills.py
- name: Install dependencies
run: npm ci
working-directory: website
- name: Build Docusaurus
run: npm run build
working-directory: website
- name: Stage deployment
run: |
mkdir -p _site/docs
cp -r landingpage/* _site/
cp -r website/build/* _site/docs/
echo "hermes-agent.nousresearch.com" > _site/CNAME
- name: Upload artifact
uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3
with:
path: _site
- name: Deploy to GitHub Pages
id: deploy
uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4

View File

@@ -12,10 +12,9 @@ jobs:
scan:
name: Scan PR for supply chain risks
runs-on: ubuntu-latest
container: catthehacker/ubuntu:act-22.04
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
@@ -150,6 +149,62 @@ jobs:
"
fi
# --- CI/CD workflow files modified ---
WORKFLOW_HITS=$(git diff --name-only "$BASE".."$HEAD" | grep -E '\.github/workflows/.*\.ya?ml$' || true)
if [ -n "$WORKFLOW_HITS" ]; then
FINDINGS="${FINDINGS}
### ⚠️ WARNING: CI/CD workflow files modified
Changes to workflow files can alter build pipelines, inject steps, or modify permissions. Verify no unauthorized actions or secrets access were added.
**Files:**
\`\`\`
${WORKFLOW_HITS}
\`\`\`
"
fi
# --- Dockerfile / container build files modified ---
DOCKER_HITS=$(git diff --name-only "$BASE".."$HEAD" | grep -iE '(Dockerfile|\.dockerignore|docker-compose)' || true)
if [ -n "$DOCKER_HITS" ]; then
FINDINGS="${FINDINGS}
### ⚠️ WARNING: Container build files modified
Changes to Dockerfiles or compose files can alter base images, add build steps, or expose ports. Verify base image pins and build commands.
**Files:**
\`\`\`
${DOCKER_HITS}
\`\`\`
"
fi
# --- Dependency manifest files modified ---
DEP_HITS=$(git diff --name-only "$BASE".."$HEAD" | grep -E '(pyproject\.toml|requirements.*\.txt|package\.json|Gemfile|go\.mod|Cargo\.toml)$' || true)
if [ -n "$DEP_HITS" ]; then
FINDINGS="${FINDINGS}
### ⚠️ WARNING: Dependency manifest files modified
Changes to dependency files can introduce new packages or change version pins. Verify all dependency changes are intentional and from trusted sources.
**Files:**
\`\`\`
${DEP_HITS}
\`\`\`
"
fi
# --- GitHub Actions version unpinning (mutable tags instead of SHAs) ---
ACTIONS_UNPIN=$(echo "$DIFF" | grep -n '^\+' | grep 'uses:' | grep -v '#' | grep -E '@v[0-9]' | head -10 || true)
if [ -n "$ACTIONS_UNPIN" ]; then
FINDINGS="${FINDINGS}
### ⚠️ WARNING: GitHub Actions with mutable version tags
Actions should be pinned to full commit SHAs (not \`@v4\`, \`@v5\`). Mutable tags can be retargeted silently if a maintainer account is compromised.
**Matches:**
\`\`\`
${ACTIONS_UNPIN}
\`\`\`
"
fi
# --- Output results ---
if [ -n "$FINDINGS" ]; then
echo "found=true" >> "$GITHUB_OUTPUT"
@@ -184,7 +239,7 @@ jobs:
---
*Automated scan triggered by [supply-chain-audit](/.github/workflows/supply-chain-audit.yml). If this is a false positive, a maintainer can approve after manual review.*"
gh pr comment "${{ github.event.pull_request.number }}" --body "$BODY"
gh pr comment "${{ github.event.pull_request.number }}" --body "$BODY" || echo "::warning::Could not post PR comment (expected for fork PRs — GITHUB_TOKEN is read-only)"
- name: Fail on critical findings
if: steps.scan.outputs.critical == 'true'

View File

@@ -6,6 +6,9 @@ on:
pull_request:
branches: [main]
permissions:
contents: read
# Cancel in-progress runs for the same PR/branch
concurrency:
group: tests-${{ github.ref }}
@@ -14,14 +17,20 @@ concurrency:
jobs:
test:
runs-on: ubuntu-latest
container: catthehacker/ubuntu:act-22.04
timeout-minutes: 10
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Install system dependencies
run: sudo apt-get update && sudo apt-get install -y ripgrep
- name: Check for hardcoded paths
run: python3 scripts/lint_hardcoded_paths.py || true
continue-on-error: true
- name: Install uv
uses: astral-sh/setup-uv@v5
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
- name: Set up Python 3.11
run: uv python install 3.11
@@ -47,10 +56,10 @@ jobs:
timeout-minutes: 10
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Install uv
uses: astral-sh/setup-uv@v5
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
- name: Set up Python 3.11
run: uv python install 3.11

4
.gitignore vendored
View File

@@ -51,6 +51,9 @@ ignored/
.worktrees/
environments/benchmarks/evals/
# Web UI build output
hermes_cli/web_dist/
# Release script temp files
.release_notes.md
mini-swe-agent/
@@ -58,3 +61,4 @@ mini-swe-agent/
# Nix
.direnv/
result
website/static/api/skills-index.json

107
.mailmap Normal file
View File

@@ -0,0 +1,107 @@
# .mailmap — canonical author mapping for git shortlog / git log / GitHub
# Format: Canonical Name <canonical@email> <commit@email>
# See: https://git-scm.com/docs/gitmailmap
#
# This maps commit emails to GitHub noreply addresses so that:
# 1. `git shortlog -sn` shows deduplicated contributor counts
# 2. GitHub's contributor graph can attribute commits correctly
# 3. Contributors with personal/work emails get proper credit
#
# When adding entries: use the contributor's GitHub noreply email as canonical
# so GitHub can link commits to their profile.
# === Teknium (multiple emails) ===
Teknium <127238744+teknium1@users.noreply.github.com> <teknium1@gmail.com>
Teknium <127238744+teknium1@users.noreply.github.com> <teknium@nousresearch.com>
# === Contributors — personal/work emails mapped to GitHub noreply ===
# Format: Canonical Name <GH-noreply> <commit-email>
# Verified via GH API email search
luyao618 <364939526@qq.com> <364939526@qq.com>
ethernet8023 <arilotter@gmail.com> <arilotter@gmail.com>
nicoloboschi <boschi1997@gmail.com> <boschi1997@gmail.com>
cherifya <chef.ya@gmail.com> <chef.ya@gmail.com>
BongSuCHOI <chlqhdtn98@gmail.com> <chlqhdtn98@gmail.com>
dsocolobsky <dsocolobsky@gmail.com> <dsocolobsky@gmail.com>
pefontana <fontana.pedro93@gmail.com> <fontana.pedro93@gmail.com>
Helmi <frank@helmschrott.de> <frank@helmschrott.de>
hata1234 <hata1234@gmail.com> <hata1234@gmail.com>
# Verified via PR investigation / salvage PR bodies
DeployFaith <agents@kylefrench.dev> <agents@kylefrench.dev>
flobo3 <floptopbot33@gmail.com> <floptopbot33@gmail.com>
gaixianggeng <gaixg94@gmail.com> <gaixg94@gmail.com>
KUSH42 <xush@xush.org> <xush@xush.org>
konsisumer <der@konsi.org> <der@konsi.org>
WorldInnovationsDepartment <vorvul.danylo@gmail.com> <vorvul.danylo@gmail.com>
m0n5t3r <iacobs@m0n5t3r.info> <iacobs@m0n5t3r.info>
sprmn24 <oncuevtv@gmail.com> <oncuevtv@gmail.com>
fancydirty <fancydirty@gmail.com> <fancydirty@gmail.com>
fxfitz <francis.x.fitzpatrick@gmail.com> <francis.x.fitzpatrick@gmail.com>
limars874 <limars874@gmail.com> <limars874@gmail.com>
AaronWong1999 <aaronwong1999@icloud.com> <aaronwong1999@icloud.com>
dippwho <dipp.who@gmail.com> <dipp.who@gmail.com>
duerzy <duerzy@gmail.com> <duerzy@gmail.com>
geoffwellman <geoff.wellman@gmail.com> <geoff.wellman@gmail.com>
hcshen0111 <shenhaocheng19990111@gmail.com> <shenhaocheng19990111@gmail.com>
jamesarch <han.shan@live.cn> <han.shan@live.cn>
stephenschoettler <stephenschoettler@gmail.com> <stephenschoettler@gmail.com>
Tranquil-Flow <tranquil_flow@protonmail.com> <tranquil_flow@protonmail.com>
Dusk1e <yusufalweshdemir@gmail.com> <yusufalweshdemir@gmail.com>
Awsh1 <ysfalweshcan@gmail.com> <ysfalweshcan@gmail.com>
WAXLYY <ysfwaxlycan@gmail.com> <ysfwaxlycan@gmail.com>
donrhmexe <don.rhm@gmail.com> <don.rhm@gmail.com>
hqhq1025 <1506751656@qq.com> <1506751656@qq.com>
BlackishGreen33 <s5460703@gmail.com> <s5460703@gmail.com>
tomqiaozc <zqiao@microsoft.com> <zqiao@microsoft.com>
MagicRay1217 <mingjwan@microsoft.com> <mingjwan@microsoft.com>
aaronagent <1115117931@qq.com> <1115117931@qq.com>
YoungYang963 <young@YoungdeMacBook-Pro.local> <young@YoungdeMacBook-Pro.local>
LongOddCode <haolong@microsoft.com> <haolong@microsoft.com>
Cafexss <coffeemjj@gmail.com> <coffeemjj@gmail.com>
Cygra <sjtuwbh@gmail.com> <sjtuwbh@gmail.com>
DomGrieco <dgrieco@redhat.com> <dgrieco@redhat.com>
# Duplicate email mapping (same person, multiple emails)
Sertug17 <104278804+Sertug17@users.noreply.github.com> <srhtsrht17@gmail.com>
yyovil <birdiegyal@gmail.com> <tanishq231003@gmail.com>
DomGrieco <dgrieco@redhat.com> <dgrieco@redhat.com>
dsocolobsky <dsocolobsky@gmail.com> <dylan.socolobsky@lambdaclass.com>
olafthiele <programming@olafthiele.com> <olafthiele@gmail.com>
# Verified via git display name matching GH contributor username
cokemine <aptx4561@gmail.com> <aptx4561@gmail.com>
dalianmao000 <dalianmao0107@gmail.com> <dalianmao0107@gmail.com>
emozilla <emozilla@nousresearch.com> <emozilla@nousresearch.com>
jjovalle99 <juan.ovalle@mistral.ai> <juan.ovalle@mistral.ai>
kagura-agent <kagura.chen28@gmail.com> <kagura.chen28@gmail.com>
spniyant <niyant@spicefi.xyz> <niyant@spicefi.xyz>
olafthiele <programming@olafthiele.com> <programming@olafthiele.com>
r266-tech <r2668940489@gmail.com> <r2668940489@gmail.com>
xingkongliang <tianliangjay@gmail.com> <tianliangjay@gmail.com>
win4r <win4r@outlook.com> <win4r@outlook.com>
zhouboli <zhouboli@gmail.com> <zhouboli@gmail.com>
yongtenglei <yongtenglei@gmail.com> <yongtenglei@gmail.com>
# Nous Research team
benbarclay <ben@nousresearch.com> <ben@nousresearch.com>
jquesnelle <jonny@nousresearch.com> <jonny@nousresearch.com>
# GH contributor list verified
spideystreet <dhicham.pro@gmail.com> <dhicham.pro@gmail.com>
dorukardahan <dorukardahan@hotmail.com> <dorukardahan@hotmail.com>
MustafaKara7 <karamusti912@gmail.com> <karamusti912@gmail.com>
Hmbown <hmbown@gmail.com> <hmbown@gmail.com>
kamil-gwozdz <kamil@gwozdz.me> <kamil@gwozdz.me>
kira-ariaki <kira@ariaki.me> <kira@ariaki.me>
knopki <knopki@duck.com> <knopki@duck.com>
Unayung <unayung@gmail.com> <unayung@gmail.com>
SeeYangZhi <yangzhi.see@gmail.com> <yangzhi.see@gmail.com>
Julientalbot <julien.talbot@ergonomia.re> <julien.talbot@ergonomia.re>
lesterli <lisicheng168@gmail.com> <lisicheng168@gmail.com>
JiayuuWang <jiayuw794@gmail.com> <jiayuw794@gmail.com>
tesseracttars-creator <tesseracttars@gmail.com> <tesseracttars@gmail.com>
xinbenlv <zzn+pa@zzn.im> <zzn+pa@zzn.im>
SaulJWu <saul.jj.wu@gmail.com> <saul.jj.wu@gmail.com>
angelos <angelos@oikos.lan.home.malaiwah.com> <angelos@oikos.lan.home.malaiwah.com>

View File

@@ -1,25 +0,0 @@
repos:
# Secret detection
- repo: https://github.com/gitleaks/gitleaks
rev: v8.21.2
hooks:
- id: gitleaks
name: Detect secrets with gitleaks
description: Detect hardcoded secrets, API keys, and credentials
# Basic security hygiene
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: check-added-large-files
args: ['--maxkb=500']
- id: detect-private-key
name: Detect private keys
- id: check-merge-conflict
- id: check-yaml
- id: check-toml
- id: end-of-file-fixer
- id: trailing-whitespace
args: ['--markdown-linebreak-ext=md']
- id: no-commit-to-branch
args: ['--branch', 'main']

View File

@@ -13,7 +13,7 @@ source venv/bin/activate # ALWAYS activate before running Python
```
hermes-agent/
├── run_agent.py # AIAgent class — core conversation loop
├── model_tools.py # Tool orchestration, _discover_tools(), handle_function_call()
├── model_tools.py # Tool orchestration, discover_builtin_tools(), handle_function_call()
├── toolsets.py # Toolset definitions, _HERMES_CORE_TOOLS list
├── cli.py # HermesCLI class — interactive CLI orchestrator
├── hermes_state.py # SessionDB — SQLite session store (FTS5 search)
@@ -55,7 +55,7 @@ hermes-agent/
├── gateway/ # Messaging platform gateway
│ ├── run.py # Main loop, slash commands, message dispatch
│ ├── session.py # SessionStore — conversation persistence
│ └── platforms/ # Adapters: telegram, discord, slack, whatsapp, homeassistant, signal
│ └── platforms/ # Adapters: telegram, discord, slack, whatsapp, homeassistant, signal, qqbot
├── acp_adapter/ # ACP server (VS Code / Zed / JetBrains integration)
├── cron/ # Scheduler (jobs.py, scheduler.py)
├── environments/ # RL training environments (Atropos)
@@ -181,7 +181,7 @@ if canonical == "mycommand":
## Adding New Tools
Requires changes in **3 files**:
Requires changes in **2 files**:
**1. Create `tools/your_tool.py`:**
```python
@@ -204,9 +204,9 @@ registry.register(
)
```
**2. Add import** in `model_tools.py` `_discover_tools()` list.
**2. Add to `toolsets.py`** — either `_HERMES_CORE_TOOLS` (all platforms) or a new toolset.
**3. Add to `toolsets.py`** — either `_HERMES_CORE_TOOLS` (all platforms) or a new toolset.
Auto-discovery: any `tools/*.py` file with a top-level `registry.register()` call is imported automatically — no manual import list to maintain.
The registry handles schema collection, dispatch, availability checking, and error wrapping. All handlers MUST return a JSON string.
@@ -351,8 +351,9 @@ Cache-breaking forces dramatically higher costs. The ONLY time we alter context
### Background Process Notifications (Gateway)
When `terminal(background=true, check_interval=...)` is used, the gateway runs a watcher that
pushes status updates to the user's chat. Control verbosity with `display.background_process_notifications`
When `terminal(background=true, notify_on_complete=true)` is used, the gateway runs a watcher that
detects process completion and triggers a new agent turn. Control verbosity of background process
messages with `display.background_process_notifications`
in config.yaml (or `HERMES_BACKGROUND_NOTIFICATIONS` env var):
- `all` — running-output updates + final message (default)

131
BOOT.md
View File

@@ -1,131 +0,0 @@
# BOOT.md — Hermes Agent
Fast path from clone to productive. Target: <10 minutes.
---
## 1. Prerequisites
| Tool | Why |
|---|---|
| Git | Clone + submodules |
| Python 3.11+ | Runtime requirement |
| uv | Package manager (install: `curl -LsSf https://astral.sh/uv/install.sh \| sh`) |
| Node.js 18+ | Optional — browser tools, WhatsApp bridge |
---
## 2. First-Time Setup
```bash
git clone --recurse-submodules https://forge.alexanderwhitestone.com/Timmy_Foundation/hermes-agent.git
cd hermes-agent
# Create venv
uv venv .venv --python 3.11
source .venv/bin/activate
# Install with all extras + dev tools
uv pip install -e ".[all,dev]"
```
> **Common pitfall:** If `uv` is not on PATH, the `setup-hermes.sh` script will attempt to install it, but manual `uv` install is faster.
---
## 3. Smoke Tests (< 30 sec)
```bash
python scripts/smoke_test.py
```
Expected output:
```
OK: 4 core imports
OK: 1 CLI entrypoints
Smoke tests passed.
```
If imports fail with `ModuleNotFoundError`, re-run: `uv pip install -e ".[all,dev]"`
---
## 4. Full Test Suite (excluding integration)
```bash
pytest tests/ -x --ignore=tests/integration
```
> Integration tests require a running gateway + API keys. Skip them unless you are testing platform connectivity.
---
## 5. Run the CLI
```bash
python cli.py --help
```
To start the gateway (after configuring `~/.hermes/config.yaml`):
```bash
hermes gateway run
```
---
## 6. Repo Layout for Agents
| Path | What lives here |
|---|---|
| `cli.py` | Main entrypoint |
| `hermes/` | Core agent logic |
| `toolsets/` | Built-in tool implementations |
| `skills/` | Bundled skills (loaded automatically) |
| `optional-skills/` | Official but opt-in skills |
| `tests/` | pytest suite |
| `scripts/` | Utility scripts (smoke tests, deploy validation, etc.) |
| `.gitea/workflows/` | Forge CI (smoke + build) |
| `.github/workflows/` | GitHub mirror CI |
---
## 7. Gitea Workflow Conventions
- **Push to `main`**: triggers `ci.yml` (smoke + build, < 5 min)
- **Pull requests**: same CI + notebook CI if notebooks changed
- **Merge requirement**: green smoke tests
- Security scans run on schedule via `.github/workflows/`
---
## 8. Common Pitfalls
| Symptom | Fix |
|---|---|
| `No module named httpx` | `uv pip install -e ".[all,dev]"` |
| `prompt_toolkit` missing | Included in `[all]`, but install explicitly if you used minimal deps |
| CLI hangs on start | Check `~/.hermes/config.yaml` exists and is valid YAML |
| API key errors | Copy `.env.example``.env` and fill required keys |
| Browser tools fail | Run `npm install` in repo root |
---
## 9. Quick Reference
```bash
# Reinstall after dependency changes
uv pip install -e ".[all,dev]"
# Run only smoke tests
python scripts/smoke_test.py
# Run syntax guard
python scripts/syntax_guard.py
# Start gateway
hermes gateway run
```
---
*Last updated: 2026-04-07 by Bezalel*

569
DEPLOY.md
View File

@@ -1,569 +0,0 @@
# Hermes Agent — Sovereign Deployment Runbook
> **Goal**: A new VPS can go from bare OS to a running Hermes instance in under 30 minutes using only this document.
---
## Table of Contents
1. [Prerequisites](#1-prerequisites)
2. [Environment Setup](#2-environment-setup)
3. [Secret Injection](#3-secret-injection)
4. [Installation](#4-installation)
5. [Starting the Stack](#5-starting-the-stack)
6. [Health Checks](#6-health-checks)
7. [Stop / Restart Procedures](#7-stop--restart-procedures)
8. [Zero-Downtime Restart](#8-zero-downtime-restart)
9. [Rollback Procedure](#9-rollback-procedure)
10. [Database / State Migrations](#10-database--state-migrations)
11. [Docker Compose Deployment](#11-docker-compose-deployment)
12. [systemd Deployment](#12-systemd-deployment)
13. [Monitoring & Logs](#13-monitoring--logs)
14. [Security Checklist](#14-security-checklist)
15. [Troubleshooting](#15-troubleshooting)
---
## 1. Prerequisites
| Requirement | Minimum | Recommended |
|-------------|---------|-------------|
| OS | Ubuntu 22.04 LTS | Ubuntu 24.04 LTS |
| RAM | 512 MB | 2 GB |
| CPU | 1 vCPU | 2 vCPU |
| Disk | 5 GB | 20 GB |
| Python | 3.11 | 3.12 |
| Node.js | 18 | 20 |
| Git | any | any |
**Optional but recommended:**
- Docker Engine ≥ 24 + Compose plugin (for containerised deployment)
- `curl`, `jq` (for health-check scripting)
---
## 2. Environment Setup
### 2a. Create a dedicated system user (bare-metal deployments)
```bash
sudo useradd -m -s /bin/bash hermes
sudo su - hermes
```
### 2b. Install Hermes
```bash
# Official one-liner installer
curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash
# Reload PATH so `hermes` is available
source ~/.bashrc
```
The installer places:
- The agent code at `~/.local/lib/python3.x/site-packages/` (pip editable install)
- The `hermes` entry point at `~/.local/bin/hermes`
- Default config directory at `~/.hermes/`
### 2c. Verify installation
```bash
hermes --version
hermes doctor
```
---
## 3. Secret Injection
**Rule: secrets never live in the repository. They live only in `~/.hermes/.env`.**
```bash
# Copy the template (do NOT edit the repo copy)
cp /path/to/hermes-agent/.env.example ~/.hermes/.env
chmod 600 ~/.hermes/.env
# Edit with your preferred editor
nano ~/.hermes/.env
```
### Minimum required keys
| Variable | Purpose | Where to get it |
|----------|---------|----------------|
| `OPENROUTER_API_KEY` | LLM inference | https://openrouter.ai/keys |
| `TELEGRAM_BOT_TOKEN` | Telegram gateway | @BotFather on Telegram |
### Optional but common keys
| Variable | Purpose |
|----------|---------|
| `DISCORD_BOT_TOKEN` | Discord gateway |
| `SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN` | Slack gateway |
| `EXA_API_KEY` | Web search tool |
| `FAL_KEY` | Image generation |
| `ANTHROPIC_API_KEY` | Direct Anthropic inference |
### Pre-flight validation
Before starting the stack, run:
```bash
python scripts/deploy-validate --check-ports --skip-health
```
This catches missing keys, placeholder values, and misconfigurations without touching running services.
---
## 4. Installation
### 4a. Clone the repository (if not using the installer)
```bash
git clone https://forge.alexanderwhitestone.com/Timmy_Foundation/hermes-agent.git
cd hermes-agent
pip install -e ".[all]" --user
npm install
```
### 4b. Run the setup wizard
```bash
hermes setup
```
The wizard configures your LLM provider, messaging platforms, and data directory interactively.
---
## 5. Starting the Stack
### Bare-metal (foreground — useful for first run)
```bash
# Agent + gateway combined
hermes gateway start
# Or just the CLI agent (no messaging)
hermes
```
### Bare-metal (background daemon)
```bash
hermes gateway start &
echo $! > ~/.hermes/gateway.pid
```
### Via systemd (recommended for production)
See [Section 12](#12-systemd-deployment).
### Via Docker Compose
See [Section 11](#11-docker-compose-deployment).
---
## 6. Health Checks
### 6a. API server liveness probe
The API server (enabled via `api_server` platform in gateway config) exposes `/health`:
```bash
curl -s http://127.0.0.1:8642/health | jq .
```
Expected response:
```json
{
"status": "ok",
"platform": "hermes-agent",
"version": "0.5.0",
"uptime_seconds": 123,
"gateway_state": "running",
"platforms": {
"telegram": {"state": "connected"},
"discord": {"state": "connected"}
}
}
```
| Field | Meaning |
|-------|---------|
| `status` | `"ok"` — HTTP server is alive. Any non-200 = down. |
| `gateway_state` | `"running"` — all platforms started. `"starting"` — still initialising. |
| `platforms` | Per-adapter connection state. |
### 6b. Gateway runtime status file
```bash
cat ~/.hermes/gateway_state.json | jq '{state: .gateway_state, platforms: .platforms}'
```
### 6c. Deploy-validate script
```bash
python scripts/deploy-validate
```
Runs all checks and prints a pass/fail summary. Exit code 0 = healthy.
### 6d. systemd health
```bash
systemctl status hermes-gateway
journalctl -u hermes-gateway --since "5 minutes ago"
```
---
## 7. Stop / Restart Procedures
### Graceful stop
```bash
# systemd
sudo systemctl stop hermes-gateway
# Docker Compose
docker compose -f deploy/docker-compose.yml down
# Process signal (if running ad-hoc)
kill -TERM $(cat ~/.hermes/gateway.pid)
```
### Restart
```bash
# systemd
sudo systemctl restart hermes-gateway
# Docker Compose
docker compose -f deploy/docker-compose.yml restart hermes
# Ad-hoc
hermes gateway start --replace
```
The `--replace` flag removes stale PID/lock files from an unclean shutdown before starting.
---
## 8. Zero-Downtime Restart
Hermes is a stateful long-running process (persistent sessions, active cron jobs). True zero-downtime requires careful sequencing.
### Strategy A — systemd rolling restart (recommended)
systemd's `Restart=on-failure` with a 5-second back-off ensures automatic recovery from crashes. For intentional restarts, use:
```bash
sudo systemctl reload-or-restart hermes-gateway
```
`hermes-gateway.service` uses `TimeoutStopSec=30` so in-flight agent turns finish before the old process dies.
> **Note:** Active messaging conversations will see a brief pause (< 30 s) while the gateway reconnects to platforms. The session store is file-based and persists across restarts — conversations resume where they left off.
### Strategy B — Blue/green with two HERMES_HOME directories
For zero-downtime where even a brief pause is unacceptable:
```bash
# 1. Prepare the new environment (different HERMES_HOME)
export HERMES_HOME=/home/hermes/.hermes-green
hermes setup # configure green env with same .env
# 2. Start green on a different port (e.g. 8643)
API_SERVER_PORT=8643 hermes gateway start &
# 3. Verify green is healthy
curl -s http://127.0.0.1:8643/health | jq .gateway_state
# 4. Switch load balancer (nginx/caddy) to port 8643
# 5. Gracefully stop blue
kill -TERM $(cat ~/.hermes/.hermes/gateway.pid)
```
### Strategy C — Docker Compose rolling update
```bash
# Pull the new image
docker compose -f deploy/docker-compose.yml pull hermes
# Recreate with zero-downtime if you have a replicated setup
docker compose -f deploy/docker-compose.yml up -d --no-deps hermes
```
Docker stops the old container only after the new one passes its healthcheck.
---
## 9. Rollback Procedure
### 9a. Code rollback (pip install)
```bash
# Find the previous version tag
git log --oneline --tags | head -10
# Roll back to a specific tag
git checkout v0.4.0
pip install -e ".[all]" --user --quiet
# Restart the gateway
sudo systemctl restart hermes-gateway
```
### 9b. Docker image rollback
```bash
# Pull a specific version
docker pull ghcr.io/nousresearch/hermes-agent:v0.4.0
# Update docker-compose.yml image tag, then:
docker compose -f deploy/docker-compose.yml up -d
```
### 9c. State / data rollback
The data directory (`~/.hermes/` or the Docker volume `hermes_data`) contains sessions, memories, cron jobs, and the response store. Back it up before every update:
```bash
# Backup (run BEFORE updating)
tar czf ~/backups/hermes_data_$(date +%F_%H%M).tar.gz ~/.hermes/
# Restore from backup
sudo systemctl stop hermes-gateway
rm -rf ~/.hermes/
tar xzf ~/backups/hermes_data_2026-04-06_1200.tar.gz -C ~/
sudo systemctl start hermes-gateway
```
> **Tested rollback**: The rollback procedure above was validated in staging on 2026-04-06. Data integrity was confirmed by checking session count before/after: `ls ~/.hermes/sessions/ | wc -l`.
---
## 10. Database / State Migrations
Hermes uses two persistent stores:
| Store | Location | Format |
|-------|----------|--------|
| Session store | `~/.hermes/sessions/*.json` | JSON files |
| Response store (API server) | `~/.hermes/response_store.db` | SQLite WAL |
| Gateway state | `~/.hermes/gateway_state.json` | JSON |
| Memories | `~/.hermes/memories/*.md` | Markdown files |
| Cron jobs | `~/.hermes/cron/*.json` | JSON files |
### Migration steps (between versions)
1. **Stop** the gateway before migrating.
2. **Backup** the data directory (see Section 9c).
3. **Check release notes** for migration instructions (see `RELEASE_*.md`).
4. **Run** `hermes doctor` after starting the new version — it validates state compatibility.
5. **Verify** health via `python scripts/deploy-validate`.
There are currently no SQL migrations to run manually. The SQLite schema is
created automatically on first use with `CREATE TABLE IF NOT EXISTS`.
---
## 11. Docker Compose Deployment
### First-time setup
```bash
# 1. Copy .env.example to .env in the repo root
cp .env.example .env
nano .env # fill in your API keys
# 2. Validate config before starting
python scripts/deploy-validate --skip-health
# 3. Start the stack
docker compose -f deploy/docker-compose.yml up -d
# 4. Watch startup logs
docker compose -f deploy/docker-compose.yml logs -f
# 5. Verify health
curl -s http://127.0.0.1:8642/health | jq .
```
### Updating to a new version
```bash
# Pull latest image
docker compose -f deploy/docker-compose.yml pull
# Recreate container (Docker waits for healthcheck before stopping old)
docker compose -f deploy/docker-compose.yml up -d
# Watch logs
docker compose -f deploy/docker-compose.yml logs -f --since 2m
```
### Data backup (Docker)
```bash
docker run --rm \
-v hermes_data:/data \
-v $(pwd)/backups:/backup \
alpine tar czf /backup/hermes_data_$(date +%F).tar.gz /data
```
---
## 12. systemd Deployment
### Install unit files
```bash
# From the repo root
sudo cp deploy/hermes-agent.service /etc/systemd/system/
sudo cp deploy/hermes-gateway.service /etc/systemd/system/
sudo systemctl daemon-reload
# Enable on boot + start now
sudo systemctl enable --now hermes-gateway
# (Optional) also run the CLI agent as a background service
# sudo systemctl enable --now hermes-agent
```
### Adjust the unit file for your user/paths
Edit `/etc/systemd/system/hermes-gateway.service`:
```ini
[Service]
User=youruser # change from 'hermes'
WorkingDirectory=/home/youruser
EnvironmentFile=/home/youruser/.hermes/.env
ExecStart=/home/youruser/.local/bin/hermes gateway start --replace
```
Then:
```bash
sudo systemctl daemon-reload
sudo systemctl restart hermes-gateway
```
### Verify
```bash
systemctl status hermes-gateway
journalctl -u hermes-gateway -f
```
---
## 13. Monitoring & Logs
### Log locations
| Log | Location |
|-----|----------|
| Gateway (systemd) | `journalctl -u hermes-gateway` |
| Gateway (Docker) | `docker compose logs hermes` |
| Session trajectories | `~/.hermes/logs/session_*.json` |
| Deploy events | `~/.hermes/logs/deploy.log` |
| Runtime state | `~/.hermes/gateway_state.json` |
### Useful log commands
```bash
# Last 100 lines, follow
journalctl -u hermes-gateway -n 100 -f
# Errors only
journalctl -u hermes-gateway -p err --since today
# Docker: structured logs with timestamps
docker compose -f deploy/docker-compose.yml logs --timestamps hermes
```
### Alerting
Add a cron job on the host to page you if the health check fails:
```bash
# /etc/cron.d/hermes-healthcheck
* * * * * root curl -sf http://127.0.0.1:8642/health > /dev/null || \
echo "Hermes unhealthy at $(date)" | mail -s "ALERT: Hermes down" ops@example.com
```
---
## 14. Security Checklist
- [ ] `.env` has permissions `600` and is **not** tracked by git (`git ls-files .env` returns nothing).
- [ ] `API_SERVER_KEY` is set if the API server is exposed beyond `127.0.0.1`.
- [ ] API server is bound to `127.0.0.1` (not `0.0.0.0`) unless behind a TLS-terminating reverse proxy.
- [ ] Firewall allows only the ports your platforms require (no unnecessary open ports).
- [ ] systemd unit uses `NoNewPrivileges=true`, `PrivateTmp=true`, `ProtectSystem=strict`.
- [ ] Docker container has resource limits set (`deploy.resources.limits`).
- [ ] Backups of `~/.hermes/` are stored outside the server (e.g. S3, remote NAS).
- [ ] `hermes doctor` returns no errors on the running instance.
- [ ] `python scripts/deploy-validate` exits 0 after every configuration change.
---
## 15. Troubleshooting
### Gateway won't start
```bash
hermes gateway start --replace # clears stale PID files
# Check for port conflicts
ss -tlnp | grep 8642
# Verbose logs
HERMES_LOG_LEVEL=DEBUG hermes gateway start
```
### Health check returns `gateway_state: "starting"` for more than 60 s
Platform adapters take time to authenticate (especially Telegram + Discord). Check logs for auth errors:
```bash
journalctl -u hermes-gateway --since "2 minutes ago" | grep -i "error\|token\|auth"
```
### `/health` returns connection refused
The API server platform may not be enabled. Verify your gateway config (`~/.hermes/config.yaml`) includes:
```yaml
gateway:
platforms:
- api_server
```
### Rollback needed after failed update
See [Section 9](#9-rollback-procedure). If you backed up before updating, rollback takes < 5 minutes.
### Sessions lost after restart
Sessions are file-based in `~/.hermes/sessions/`. They persist across restarts. If they are gone, check:
```bash
ls -la ~/.hermes/sessions/
# Verify the volume is mounted (Docker):
docker exec hermes-agent ls /opt/data/sessions/
```
---
*This runbook is owned by the Bezalel epic backlog. Update it whenever deployment procedures change.*

View File

@@ -1,23 +1,44 @@
FROM ghcr.io/astral-sh/uv:0.11.6-python3.13-trixie@sha256:b3c543b6c4f23a5f2df22866bd7857e5d304b67a564f4feab6ac22044dde719b AS uv_source
FROM tianon/gosu:1.19-trixie@sha256:3b176695959c71e123eb390d427efc665eeb561b1540e82679c15e992006b8b9 AS gosu_source
FROM debian:13.4
# Disable Python stdout buffering to ensure logs are printed immediately
ENV PYTHONUNBUFFERED=1
# Store Playwright browsers outside the volume mount so the build-time
# install survives the /opt/data volume overlay at runtime.
ENV PLAYWRIGHT_BROWSERS_PATH=/opt/hermes/.playwright
# Install system dependencies in one layer, clear APT cache
RUN apt-get update && \
apt-get install -y --no-install-recommends \
build-essential nodejs npm python3 python3-pip ripgrep ffmpeg gcc python3-dev libffi-dev && \
build-essential nodejs npm python3 ripgrep ffmpeg gcc python3-dev libffi-dev procps git && \
rm -rf /var/lib/apt/lists/*
# Non-root user for runtime; UID can be overridden via HERMES_UID at runtime
RUN useradd -u 10000 -m -d /opt/data hermes
COPY --chmod=0755 --from=gosu_source /gosu /usr/local/bin/
COPY --chmod=0755 --from=uv_source /usr/local/bin/uv /usr/local/bin/uvx /usr/local/bin/
COPY . /opt/hermes
WORKDIR /opt/hermes
# Install Python and Node dependencies in one layer, no cache
RUN pip install --no-cache-dir -e ".[all]" --break-system-packages && \
npm install --prefer-offline --no-audit && \
# Install Node dependencies and Playwright as root (--with-deps needs apt)
RUN npm install --prefer-offline --no-audit && \
npx playwright install --with-deps chromium --only-shell && \
cd /opt/hermes/scripts/whatsapp-bridge && \
npm install --prefer-offline --no-audit && \
npm cache clean --force
WORKDIR /opt/hermes
# Hand ownership to hermes user, then install Python deps in a virtualenv
RUN chown -R hermes:hermes /opt/hermes
USER hermes
RUN uv venv && \
uv pip install --no-cache-dir -e ".[all]"
USER root
RUN chmod +x /opt/hermes/docker/entrypoint.sh
ENV HERMES_HOME=/opt/data

View File

@@ -1,589 +0,0 @@
# Hermes Agent Performance Analysis Report
**Date:** 2025-03-30
**Scope:** Entire codebase - run_agent.py, gateway, tools
**Lines Analyzed:** 50,000+ lines of Python code
---
## Executive Summary
The codebase exhibits **severe performance bottlenecks** across multiple dimensions. The monolithic architecture, excessive synchronous I/O, lack of caching, and inefficient algorithms result in significant performance degradation under load.
**Critical Issues Found:**
- 113 lock primitives (potential contention points)
- 482 sleep calls (blocking delays)
- 1,516 JSON serialization calls (CPU overhead)
- 8,317-line run_agent.py (unmaintainable, slow import)
- Synchronous HTTP requests in async contexts
---
## 1. HOTSPOT ANALYSIS (Slowest Code Paths)
### 1.1 run_agent.py - The Monolithic Bottleneck
**File Size:** 8,317 lines, 419KB
**Severity:** CRITICAL
**Issues:**
```python
# Lines 460-1000: Massive __init__ method with 50+ parameters
# Lines 3759-3826: _anthropic_messages_create - blocking API calls
# Lines 3827-3920: _interruptible_api_call - sync wrapper around async
# Lines 2269-2297: _hydrate_todo_store - O(n) history scan on every message
# Lines 2158-2222: _save_session_log - synchronous file I/O on every turn
```
**Performance Impact:**
- Import time: ~2-3 seconds (circular dependencies, massive imports)
- Initialization: 500ms+ per AIAgent instance
- Memory footprint: ~50MB per agent instance
- Session save: 50-100ms blocking I/O per turn
### 1.2 Gateway Stream Consumer - Busy-Wait Pattern
**File:** gateway/stream_consumer.py
**Lines:** 88-147
```python
# PROBLEM: Busy-wait loop with fixed 50ms sleep
while True:
try:
item = self._queue.get_nowait() # Non-blocking
except queue.Empty:
break
# ...
await asyncio.sleep(0.05) # 50ms delay = max 20 updates/sec
```
**Issues:**
- Fixed 50ms sleep limits throughput to 20 updates/second
- No adaptive back-off
- Wastes CPU cycles polling
### 1.3 Context Compression - Expensive LLM Calls
**File:** agent/context_compressor.py
**Lines:** 250-369
```python
def _generate_summary(self, turns_to_summarize: List[Dict]) -> Optional[str]:
# Calls LLM for EVERY compression - $$$ and latency
response = call_llm(
messages=[{"role": "user", "content": prompt}],
max_tokens=summary_budget * 2, # Expensive!
)
```
**Issues:**
- Synchronous LLM call blocks agent loop
- No caching of similar contexts
- Repeated serialization of same messages
### 1.4 Web Tools - Synchronous HTTP Requests
**File:** tools/web_tools.py
**Lines:** 171-188
```python
def _tavily_request(endpoint: str, payload: dict) -> dict:
response = httpx.post(url, json=payload, timeout=60) # BLOCKING
response.raise_for_status()
return response.json()
```
**Issues:**
- 60-second blocking timeout
- No async/await pattern
- Serial request pattern (no parallelism)
### 1.5 SQLite Session Store - Write Contention
**File:** hermes_state.py
**Lines:** 116-215
```python
def _execute_write(self, fn: Callable) -> T:
for attempt in range(self._WRITE_MAX_RETRIES): # 15 retries!
try:
with self._lock: # Global lock
self._conn.execute("BEGIN IMMEDIATE")
result = fn(self._conn)
self._conn.commit()
except sqlite3.OperationalError:
time.sleep(random.uniform(0.020, 0.150)) # Random jitter
```
**Issues:**
- Global thread lock on all writes
- 15 retry attempts with jitter
- Serializes all DB operations
---
## 2. MEMORY PROFILING RECOMMENDATIONS
### 2.1 Memory Leaks Identified
**A. Agent Cache in Gateway (run.py lines 406-413)**
```python
# PROBLEM: Unbounded cache growth
self._agent_cache: Dict[str, tuple] = {} # Never evicted!
self._agent_cache_lock = _threading.Lock()
```
**Fix:** Implement LRU cache with maxsize=100
**B. Message History in run_agent.py**
```python
self._session_messages: List[Dict[str, Any]] = [] # Unbounded!
```
**Fix:** Implement sliding window or compression threshold
**C. Read Tracker in file_tools.py (lines 57-62)**
```python
_read_tracker: dict = {} # Per-task state never cleaned
```
**Fix:** TTL-based eviction
### 2.2 Large Object Retention
**A. Tool Registry (tools/registry.py)**
- Holds ALL tool schemas in memory (~5MB)
- No lazy loading
**B. Model Metadata Cache (agent/model_metadata.py)**
- Caches all model info indefinitely
- No TTL or size limits
### 2.3 String Duplication
**Issue:** 1,516 JSON serialize/deserialize calls create massive string duplication
**Recommendation:**
- Use orjson for 10x faster JSON processing
- Implement string interning for repeated keys
- Use MessagePack for internal serialization
---
## 3. ASYNC CONVERSION OPPORTUNITIES
### 3.1 High-Priority Conversions
| File | Function | Current | Impact |
|------|----------|---------|--------|
| tools/web_tools.py | web_search_tool | Sync | HIGH |
| tools/web_tools.py | web_extract_tool | Sync | HIGH |
| tools/browser_tool.py | browser_navigate | Sync | HIGH |
| tools/terminal_tool.py | terminal_tool | Sync | MEDIUM |
| tools/file_tools.py | read_file_tool | Sync | MEDIUM |
| agent/context_compressor.py | _generate_summary | Sync | HIGH |
| run_agent.py | _save_session_log | Sync | MEDIUM |
### 3.2 Async Bridge Overhead
**File:** model_tools.py (lines 81-126)
```python
def _run_async(coro):
# PROBLEM: Creates thread pool for EVERY async call!
if loop and loop.is_running():
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
future = pool.submit(asyncio.run, coro)
return future.result(timeout=300)
```
**Issues:**
- Creates/destroys thread pool per call
- 300-second blocking wait
- No connection pooling
**Fix:** Use persistent async loop with asyncio.gather()
### 3.3 Gateway Async Patterns
**Current:**
```python
# gateway/run.py - Mixed sync/async
async def handle_message(self, event):
result = self.run_agent_sync(event) # Blocks event loop!
```
**Recommended:**
```python
async def handle_message(self, event):
result = await asyncio.to_thread(self.run_agent_sync, event)
```
---
## 4. CACHING STRATEGY IMPROVEMENTS
### 4.1 Missing Cache Layers
**A. Tool Schema Resolution**
```python
# model_tools.py - Rebuilds schemas every call
filtered_tools = registry.get_definitions(tools_to_include)
```
**Fix:** Cache tool definitions keyed by (enabled_toolsets, disabled_toolsets)
**B. Model Metadata Fetching**
```python
# agent/model_metadata.py - Fetches on every init
fetch_model_metadata() # HTTP request!
```
**Fix:** Cache with 1-hour TTL (already noted but not consistently applied)
**C. Session Context Building**
```python
# gateway/session.py - Rebuilds prompt every message
build_session_context_prompt(context) # String formatting overhead
```
**Fix:** Cache with LRU for repeated contexts
### 4.2 Cache Invalidation Strategy
**Recommended Implementation:**
```python
from functools import lru_cache
from cachetools import TTLCache
# For tool definitions
@lru_cache(maxsize=128)
def get_cached_tool_definitions(enabled_toolsets: tuple, disabled_toolsets: tuple):
return registry.get_definitions(set(enabled_toolsets))
# For API responses
model_metadata_cache = TTLCache(maxsize=100, ttl=3600)
```
### 4.3 Redis/Memcached for Distributed Caching
For multi-instance gateway deployments:
- Cache session state in Redis
- Share tool definitions across workers
- Distributed rate limiting
---
## 5. PERFORMANCE OPTIMIZATIONS (15+)
### 5.1 Critical Optimizations
**OPT-1: Async Web Tool HTTP Client**
```python
# tools/web_tools.py - Replace with async
import httpx
async def web_search_tool(query: str) -> dict:
async with httpx.AsyncClient() as client:
response = await client.post(url, json=payload, timeout=60)
return response.json()
```
**Impact:** 10x throughput improvement for concurrent requests
**OPT-2: Streaming JSON Parser**
```python
# Replace json.loads for large responses
import ijson # Incremental JSON parser
async def parse_large_response(stream):
async for item in ijson.items(stream, 'results.item'):
yield item
```
**Impact:** 50% memory reduction for large API responses
**OPT-3: Connection Pooling**
```python
# Single shared HTTP client
_http_client: Optional[httpx.AsyncClient] = None
async def get_http_client() -> httpx.AsyncClient:
global _http_client
if _http_client is None:
_http_client = httpx.AsyncClient(
limits=httpx.Limits(max_keepalive_connections=20, max_connections=100)
)
return _http_client
```
**Impact:** Eliminates connection overhead (50-100ms per request)
**OPT-4: Compiled Regex Caching**
```python
# run_agent.py line 243-256 - Compiles regex every call!
_DESTRUCTIVE_PATTERNS = re.compile(...) # Module level - good
# But many patterns are inline - cache them
@lru_cache(maxsize=1024)
def get_path_pattern(path: str):
return re.compile(re.escape(path) + r'.*')
```
**Impact:** 20% CPU reduction in path matching
**OPT-5: Lazy Tool Discovery**
```python
# model_tools.py - Imports ALL tools at startup
def _discover_tools():
for mod_name in _modules: # 16 imports!
importlib.import_module(mod_name)
# Fix: Lazy import on first use
@lru_cache(maxsize=1)
def _get_tool_module(name: str):
return importlib.import_module(f"tools.{name}")
```
**Impact:** 2-second faster startup time
### 5.2 Database Optimizations
**OPT-6: SQLite Write Batching**
```python
# hermes_state.py - Current: one write per operation
# Fix: Batch writes
def batch_insert_messages(self, messages: List[Dict]):
with self._lock:
self._conn.execute("BEGIN IMMEDIATE")
try:
self._conn.executemany(
"INSERT INTO messages (...) VALUES (...)",
[(m['session_id'], m['content'], ...) for m in messages]
)
self._conn.commit()
except:
self._conn.rollback()
```
**Impact:** 10x faster for bulk operations
**OPT-7: Connection Pool for SQLite**
```python
# Use sqlalchemy with connection pooling
from sqlalchemy import create_engine
from sqlalchemy.pool import QueuePool
engine = create_engine(
'sqlite:///state.db',
poolclass=QueuePool,
pool_size=5,
max_overflow=10
)
```
### 5.3 Memory Optimizations
**OPT-8: Streaming Message Processing**
```python
# run_agent.py - Current: loads ALL messages into memory
# Fix: Generator-based processing
def iter_messages(self, session_id: str):
cursor = self._conn.execute(
"SELECT content FROM messages WHERE session_id = ? ORDER BY timestamp",
(session_id,)
)
for row in cursor:
yield json.loads(row['content'])
```
**OPT-9: String Interning**
```python
import sys
# For repeated string keys in JSON
INTERN_KEYS = {'role', 'content', 'tool_calls', 'function'}
def intern_message(msg: dict) -> dict:
return {sys.intern(k) if k in INTERN_KEYS else k: v
for k, v in msg.items()}
```
### 5.4 Algorithmic Optimizations
**OPT-10: O(1) Tool Lookup**
```python
# tools/registry.py - Current: linear scan
for name in sorted(tool_names): # O(n log n)
entry = self._tools.get(name)
# Fix: Pre-computed sets
self._tool_index = {name: entry for name, entry in self._tools.items()}
```
**OPT-11: Path Overlap Detection**
```python
# run_agent.py lines 327-335 - O(n*m) comparison
def _paths_overlap(left: Path, right: Path) -> bool:
# Current: compares ALL path parts
# Fix: Hash-based lookup
from functools import lru_cache
@lru_cache(maxsize=1024)
def get_path_hash(path: Path) -> str:
return str(path.resolve())
```
**OPT-12: Parallel Tool Execution**
```python
# run_agent.py - Current: sequential or limited parallel
# Fix: asyncio.gather for safe tools
async def execute_tool_batch(tool_calls):
safe_tools = [tc for tc in tool_calls if tc.name in _PARALLEL_SAFE_TOOLS]
unsafe_tools = [tc for tc in tool_calls if tc.name not in _PARALLEL_SAFE_TOOLS]
# Execute safe tools in parallel
safe_results = await asyncio.gather(*[
execute_tool(tc) for tc in safe_tools
])
# Execute unsafe tools sequentially
unsafe_results = []
for tc in unsafe_tools:
unsafe_results.append(await execute_tool(tc))
```
### 5.5 I/O Optimizations
**OPT-13: Async File Operations**
```python
# utils.py - atomic_json_write uses blocking I/O
# Fix: aiofiles
import aiofiles
async def async_atomic_json_write(path: Path, data: dict):
tmp_path = path.with_suffix('.tmp')
async with aiofiles.open(tmp_path, 'w') as f:
await f.write(json.dumps(data))
tmp_path.rename(path)
```
**OPT-14: Memory-Mapped Files for Large Logs**
```python
# For trajectory files
import mmap
def read_trajectory_chunk(path: Path, offset: int, size: int):
with open(path, 'rb') as f:
with mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) as mm:
return mm[offset:offset+size]
```
**OPT-15: Compression for Session Storage**
```python
import lz4.frame # Fast compression
class CompressedSessionDB(SessionDB):
def _compress_message(self, content: str) -> bytes:
return lz4.frame.compress(content.encode())
def _decompress_message(self, data: bytes) -> str:
return lz4.frame.decompress(data).decode()
```
**Impact:** 70% storage reduction, faster I/O
---
## 6. ADDITIONAL RECOMMENDATIONS
### 6.1 Architecture Improvements
1. **Split run_agent.py** into modules:
- agent/core.py - Core conversation loop
- agent/tools.py - Tool execution
- agent/persistence.py - Session management
- agent/api.py - API client management
2. **Implement Event-Driven Architecture:**
- Use message queue for tool execution
- Decouple gateway from agent logic
- Enable horizontal scaling
3. **Add Metrics Collection:**
```python
from prometheus_client import Histogram, Counter
tool_execution_time = Histogram('tool_duration_seconds', 'Time spent in tools', ['tool_name'])
api_call_counter = Counter('api_calls_total', 'Total API calls', ['provider', 'status'])
```
### 6.2 Profiling Recommendations
**Immediate Actions:**
```bash
# 1. Profile import time
python -X importtime -c "import run_agent" 2>&1 | head -100
# 2. Memory profiling
pip install memory_profiler
python -m memory_profiler run_agent.py
# 3. CPU profiling
pip install py-spy
py-spy top -- python run_agent.py
# 4. Async profiling
pip install austin
austin python run_agent.py
```
### 6.3 Load Testing
```python
# locustfile.py for gateway load testing
from locust import HttpUser, task
class GatewayUser(HttpUser):
@task
def send_message(self):
self.client.post("/webhook/telegram", json={
"message": {"text": "Hello", "chat": {"id": 123}}
})
```
---
## 7. PRIORITY MATRIX
| Priority | Optimization | Effort | Impact |
|----------|-------------|--------|--------|
| P0 | Async web tools | Low | 10x throughput |
| P0 | HTTP connection pooling | Low | 100ms latency |
| P0 | SQLite batch writes | Low | 10x DB perf |
| P1 | Tool lazy loading | Low | 2s startup |
| P1 | Agent cache LRU | Low | Memory leak fix |
| P1 | Streaming JSON | Medium | 50% memory |
| P2 | Code splitting | High | Maintainability |
| P2 | Redis caching | Medium | Scalability |
| P2 | Compression | Low | 70% storage |
---
## 8. CONCLUSION
The Hermes Agent codebase has significant performance debt accumulated from rapid feature development. The monolithic architecture and synchronous I/O patterns are the primary bottlenecks.
**Quick Wins (1 week):**
- Async HTTP clients
- Connection pooling
- SQLite batching
- Lazy loading
**Medium Term (1 month):**
- Code modularization
- Caching layers
- Streaming processing
**Long Term (3 months):**
- Event-driven architecture
- Horizontal scaling
- Distributed caching
**Estimated Performance Gains:**
- Latency: 50-70% reduction
- Throughput: 10x improvement
- Memory: 40% reduction
- Startup: 3x faster

View File

@@ -1,241 +0,0 @@
# Performance Hotspots Quick Reference
## Critical Files to Optimize
### 1. run_agent.py (8,317 lines, 419KB)
```
Lines 460-1000: Massive __init__ - 50+ params, slow startup
Lines 2158-2222: _save_session_log - blocking I/O every turn
Lines 2269-2297: _hydrate_todo_store - O(n) history scan
Lines 3759-3826: _anthropic_messages_create - blocking API calls
Lines 3827-3920: _interruptible_api_call - sync/async bridge overhead
```
**Fix Priority: CRITICAL**
- Split into modules
- Add async session logging
- Cache history hydration
---
### 2. gateway/run.py (6,016 lines, 274KB)
```
Lines 406-413: _agent_cache - unbounded growth, memory leak
Lines 464-493: _get_or_create_gateway_honcho - blocking init
Lines 2800+: run_agent_sync - blocks event loop
```
**Fix Priority: HIGH**
- Implement LRU cache
- Use asyncio.to_thread()
---
### 3. gateway/stream_consumer.py
```
Lines 88-147: Busy-wait loop with 50ms sleep
Max 20 updates/sec throughput
```
**Fix Priority: MEDIUM**
- Use asyncio.Event for signaling
- Adaptive back-off
---
### 4. tools/web_tools.py (1,843 lines)
```
Lines 171-188: _tavily_request - sync httpx call, 60s timeout
Lines 256-301: process_content_with_llm - sync LLM call
```
**Fix Priority: CRITICAL**
- Convert to async
- Add connection pooling
---
### 5. tools/browser_tool.py (1,955 lines)
```
Lines 194-208: _resolve_cdp_override - sync requests call
Lines 234-257: _get_cloud_provider - blocking config read
```
**Fix Priority: HIGH**
- Async HTTP client
- Cache config reads
---
### 6. tools/terminal_tool.py (1,358 lines)
```
Lines 66-92: _check_disk_usage_warning - blocking glob walk
Lines 167-289: _prompt_for_sudo_password - thread creation per call
```
**Fix Priority: MEDIUM**
- Async disk check
- Thread pool reuse
---
### 7. tools/file_tools.py (563 lines)
```
Lines 53-62: _read_tracker - unbounded dict growth
Lines 195-262: read_file_tool - sync file I/O
```
**Fix Priority: MEDIUM**
- TTL-based cleanup
- aiofiles for async I/O
---
### 8. agent/context_compressor.py (676 lines)
```
Lines 250-369: _generate_summary - expensive LLM call
Lines 490-500: _find_tail_cut_by_tokens - O(n) token counting
```
**Fix Priority: HIGH**
- Background compression task
- Cache summaries
---
### 9. hermes_state.py (1,274 lines)
```
Lines 116-215: _execute_write - global lock, 15 retries
Lines 143-156: SQLite with WAL but single connection
```
**Fix Priority: HIGH**
- Connection pooling
- Batch writes
---
### 10. model_tools.py (472 lines)
```
Lines 81-126: _run_async - creates ThreadPool per call!
Lines 132-170: _discover_tools - imports ALL tools at startup
```
**Fix Priority: CRITICAL**
- Persistent thread pool
- Lazy tool loading
---
## Quick Fixes (Copy-Paste Ready)
### Fix 1: LRU Cache for Agent Cache
```python
from functools import lru_cache
from cachetools import TTLCache
# In gateway/run.py
self._agent_cache: Dict[str, tuple] = TTLCache(maxsize=100, ttl=3600)
```
### Fix 2: Async HTTP Client
```python
# In tools/web_tools.py
import httpx
_http_client: Optional[httpx.AsyncClient] = None
async def get_http_client() -> httpx.AsyncClient:
global _http_client
if _http_client is None:
_http_client = httpx.AsyncClient(timeout=60)
return _http_client
```
### Fix 3: Connection Pool for DB
```python
# In hermes_state.py
from sqlalchemy import create_engine
from sqlalchemy.pool import QueuePool
engine = create_engine(
'sqlite:///state.db',
poolclass=QueuePool,
pool_size=5,
max_overflow=10
)
```
### Fix 4: Lazy Tool Loading
```python
# In model_tools.py
@lru_cache(maxsize=1)
def _get_discovered_tools():
"""Cache tool discovery after first call"""
_discover_tools()
return registry
```
### Fix 5: Batch Session Writes
```python
# In run_agent.py
async def _save_session_log_async(self, messages):
"""Non-blocking session save"""
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, self._save_session_log, messages)
```
---
## Performance Metrics to Track
```python
# Add these metrics
IMPORT_TIME = Gauge('import_time_seconds', 'Module import time')
AGENT_INIT_TIME = Gauge('agent_init_seconds', 'AIAgent init time')
TOOL_EXECUTION_TIME = Histogram('tool_duration_seconds', 'Tool execution', ['tool_name'])
DB_WRITE_TIME = Histogram('db_write_seconds', 'Database write time')
API_LATENCY = Histogram('api_latency_seconds', 'API call latency', ['provider'])
MEMORY_USAGE = Gauge('memory_usage_bytes', 'Process memory')
CACHE_HIT_RATE = Gauge('cache_hit_rate', 'Cache hit rate', ['cache_name'])
```
---
## One-Liner Profiling Commands
```bash
# Find slow imports
python -X importtime -c "from run_agent import AIAgent" 2>&1 | head -50
# Find blocking I/O
sudo strace -e trace=openat,read,write -c python run_agent.py 2>&1
# Memory profiling
pip install memory_profiler && python -m memory_profiler run_agent.py
# CPU profiling
pip install py-spy && py-spy record -o profile.svg -- python run_agent.py
# Find all sleep calls
grep -rn "time.sleep\|asyncio.sleep" --include="*.py" | wc -l
# Find all JSON calls
grep -rn "json.loads\|json.dumps" --include="*.py" | wc -l
# Find all locks
grep -rn "threading.Lock\|threading.RLock\|asyncio.Lock" --include="*.py"
```
---
## Expected Performance After Fixes
| Metric | Before | After | Improvement |
|--------|--------|-------|-------------|
| Startup time | 3-5s | 1-2s | 3x faster |
| API latency | 500ms | 200ms | 2.5x faster |
| Concurrent requests | 10/s | 100/s | 10x throughput |
| Memory per agent | 50MB | 30MB | 40% reduction |
| DB writes/sec | 50 | 500 | 10x throughput |
| Import time | 2s | 0.5s | 4x faster |

View File

@@ -1,163 +0,0 @@
# Performance Optimizations for run_agent.py
## Summary of Changes
This document describes the async I/O and performance optimizations applied to `run_agent.py` to fix blocking operations and improve overall responsiveness.
---
## 1. Session Log Batching (PROBLEM 1: Lines 2158-2222)
### Problem
`_save_session_log()` performed **blocking file I/O** on every conversation turn, causing:
- UI freezing during rapid message exchanges
- Unnecessary disk writes (JSON file was overwritten every turn)
- Synchronous `json.dump()` and `fsync()` blocking the main thread
### Solution
Implemented **async batching** with the following components:
#### New Methods:
- `_init_session_log_batcher()` - Initialize batching infrastructure
- `_save_session_log()` - Updated to use non-blocking batching
- `_flush_session_log_async()` - Flush writes in background thread
- `_write_session_log_sync()` - Actual blocking I/O (runs in thread pool)
- `_deferred_session_log_flush()` - Delayed flush for batching
- `_shutdown_session_log_batcher()` - Cleanup and flush on exit
#### Key Features:
- **Time-based batching**: Minimum 500ms between writes
- **Deferred flushing**: Rapid successive calls are batched
- **Thread pool**: Single-worker executor prevents concurrent write conflicts
- **Atexit cleanup**: Ensures pending logs are flushed on exit
- **Backward compatible**: Same method signature, no breaking changes
#### Performance Impact:
- Before: Every turn blocks on disk I/O (~5-20ms per write)
- After: Updates cached in memory, flushed every 500ms or on exit
- 10 rapid calls now result in ~1-2 writes instead of 10
---
## 2. Todo Store Hydration Caching (PROBLEM 2: Lines 2269-2297)
### Problem
`_hydrate_todo_store()` performed **O(n) history scan on every message**:
- Scanned entire conversation history backwards
- No caching between calls
- Re-parsed JSON for every message check
- Gateway mode creates fresh AIAgent per message, making this worse
### Solution
Implemented **result caching** with scan limiting:
#### Key Changes:
```python
# Added caching flags
self._todo_store_hydrated # Marks if hydration already done
self._todo_cache_key # Caches history object id
# Added scan limit for very long histories
scan_limit = 100 # Only scan last 100 messages
```
#### Performance Impact:
- Before: O(n) scan every call, parsing JSON for each tool message
- After: O(1) cached check, skips redundant work
- First call: Scans up to 100 messages (limited)
- Subsequent calls: <1μs cached check
---
## 3. API Call Timeouts (PROBLEM 3: Lines 3759-3826)
### Problem
`_anthropic_messages_create()` and `_interruptible_api_call()` had:
- **No timeout handling** - could block indefinitely
- 300ms polling interval for interrupt detection (sluggish)
- No timeout for OpenAI-compatible endpoints
### Solution
Added comprehensive timeout handling:
#### Changes to `_anthropic_messages_create()`:
- Added `timeout: float = 300.0` parameter (5 minutes default)
- Passes timeout to Anthropic SDK
#### Changes to `_interruptible_api_call()`:
- Added `timeout: float = 300.0` parameter
- **Reduced polling interval** from 300ms to **50ms** (6x faster interrupt response)
- Added elapsed time tracking
- Raises `TimeoutError` if API call exceeds timeout
- Force-closes clients on timeout to prevent resource leaks
- Passes timeout to OpenAI-compatible endpoints
#### Performance Impact:
- Before: Could hang forever on stuck connections
- After: Guaranteed timeout after 5 minutes (configurable)
- Interrupt response: 300ms → 50ms (6x faster)
---
## Backward Compatibility
All changes maintain **100% backward compatibility**:
1. **Session logging**: Same method signature, behavior is additive
2. **Todo hydration**: Same signature, caching is transparent
3. **API calls**: New `timeout` parameter has sensible default (300s)
No existing code needs modification to benefit from these optimizations.
---
## Testing
Run the verification script:
```bash
python3 -c "
import ast
with open('run_agent.py') as f:
source = f.read()
tree = ast.parse(source)
methods = ['_init_session_log_batcher', '_write_session_log_sync',
'_shutdown_session_log_batcher', '_hydrate_todo_store',
'_interruptible_api_call']
for node in ast.walk(tree):
if isinstance(node, ast.FunctionDef) and node.name in methods:
print(f'✓ Found {node.name}')
print('\nAll optimizations verified!')
"
```
---
## Lines Modified
| Function | Line Range | Change Type |
|----------|-----------|-------------|
| `_init_session_log_batcher` | ~2168-2178 | NEW |
| `_save_session_log` | ~2178-2230 | MODIFIED |
| `_flush_session_log_async` | ~2230-2240 | NEW |
| `_write_session_log_sync` | ~2240-2300 | NEW |
| `_deferred_session_log_flush` | ~2300-2305 | NEW |
| `_shutdown_session_log_batcher` | ~2305-2315 | NEW |
| `_hydrate_todo_store` | ~2320-2360 | MODIFIED |
| `_anthropic_messages_create` | ~3870-3890 | MODIFIED |
| `_interruptible_api_call` | ~3895-3970 | MODIFIED |
---
## Future Improvements
Potential additional optimizations:
1. Use `aiofiles` for true async file I/O (requires aiofiles dependency)
2. Batch SQLite writes in `_flush_messages_to_session_db`
3. Add compression for large session logs
4. Implement write-behind caching for checkpoint manager
---
*Optimizations implemented: 2026-03-31*

View File

@@ -13,7 +13,7 @@
**The self-improving AI agent built by [Nous Research](https://nousresearch.com).** It's the only agent with a built-in learning loop — it creates skills from experience, improves them during use, nudges itself to persist knowledge, searches its own past conversations, and builds a deepening model of who you are across sessions. Run it on a $5 VPS, a GPU cluster, or serverless infrastructure that costs nearly nothing when idle. It's not tied to your laptop — talk to it from Telegram while it works on a cloud VM.
Use any model you want — [Nous Portal](https://portal.nousresearch.com), [OpenRouter](https://openrouter.ai) (200+ models), [z.ai/GLM](https://z.ai), [Kimi/Moonshot](https://platform.moonshot.ai), [MiniMax](https://www.minimax.io), OpenAI, or your own endpoint. Switch with `hermes model` — no code changes, no lock-in.
Use any model you want — [Nous Portal](https://portal.nousresearch.com), [OpenRouter](https://openrouter.ai) (200+ models), [Xiaomi MiMo](https://platform.xiaomimimo.com), [z.ai/GLM](https://z.ai), [Kimi/Moonshot](https://platform.moonshot.ai), [MiniMax](https://www.minimax.io), [Hugging Face](https://huggingface.co), OpenAI, or your own endpoint. Switch with `hermes model` — no code changes, no lock-in.
<table>
<tr><td><b>A real terminal interface</b></td><td>Full TUI with multiline editing, slash-command autocomplete, conversation history, interrupt-and-redirect, and streaming tool output.</td></tr>
@@ -33,8 +33,10 @@ Use any model you want — [Nous Portal](https://portal.nousresearch.com), [Open
curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash
```
Works on Linux, macOS, and WSL2. The installer handles everything — Python, Node.js, dependencies, and the `hermes` command. No prerequisites except git.
Works on Linux, macOS, WSL2, and Android via Termux. The installer handles the platform-specific setup for you.
> **Android / Termux:** The tested manual path is documented in the [Termux guide](https://hermes-agent.nousresearch.com/docs/getting-started/termux). On Termux, Hermes installs a curated `.[termux]` extra because the full `.[all]` extra currently pulls Android-incompatible voice dependencies.
>
> **Windows:** Native Windows is not supported. Please install [WSL2](https://learn.microsoft.com/en-us/windows/wsl/install) and run the command above.
After installation:
@@ -165,6 +167,7 @@ python -m pytest tests/ -q
- 📚 [Skills Hub](https://agentskills.io)
- 🐛 [Issues](https://github.com/NousResearch/hermes-agent/issues)
- 💡 [Discussions](https://github.com/NousResearch/hermes-agent/discussions)
- 🔌 [HermesClaw](https://github.com/AaronWong1999/hermesclaw) — Community WeChat bridge: Run Hermes Agent and OpenClaw on the same WeChat account.
---

346
RELEASE_v0.8.0.md Normal file
View File

@@ -0,0 +1,346 @@
# Hermes Agent v0.8.0 (v2026.4.8)
**Release Date:** April 8, 2026
> The intelligence release — background task auto-notifications, free MiMo v2 Pro on Nous Portal, live model switching across all platforms, self-optimized GPT/Codex guidance, native Google AI Studio, smart inactivity timeouts, approval buttons, MCP OAuth 2.1, and 209 merged PRs with 82 resolved issues.
---
## ✨ Highlights
- **Background Process Auto-Notifications (`notify_on_complete`)** — Background tasks can now automatically notify the agent when they finish. Start a long-running process (AI model training, test suites, deployments, builds) and the agent gets notified on completion — no polling needed. The agent can keep working on other things and pick up results when they land. ([#5779](https://github.com/NousResearch/hermes-agent/pull/5779))
- **Free Xiaomi MiMo v2 Pro on Nous Portal** — Nous Portal now supports the free-tier Xiaomi MiMo v2 Pro model for auxiliary tasks (compression, vision, summarization), with free-tier model gating and pricing display in model selection. ([#6018](https://github.com/NousResearch/hermes-agent/pull/6018), [#5880](https://github.com/NousResearch/hermes-agent/pull/5880))
- **Live Model Switching (`/model` Command)** — Switch models and providers mid-session from CLI, Telegram, Discord, Slack, or any gateway platform. Aggregator-aware resolution keeps you on OpenRouter/Nous when possible, with automatic cross-provider fallback when needed. Interactive model pickers on Telegram and Discord with inline buttons. ([#5181](https://github.com/NousResearch/hermes-agent/pull/5181), [#5742](https://github.com/NousResearch/hermes-agent/pull/5742))
- **Self-Optimized GPT/Codex Tool-Use Guidance** — The agent diagnosed and patched 5 failure modes in GPT and Codex tool calling through automated behavioral benchmarking, dramatically improving reliability on OpenAI models. Includes execution discipline guidance and thinking-only prefill continuation for structured reasoning. ([#6120](https://github.com/NousResearch/hermes-agent/pull/6120), [#5414](https://github.com/NousResearch/hermes-agent/pull/5414), [#5931](https://github.com/NousResearch/hermes-agent/pull/5931))
- **Google AI Studio (Gemini) Native Provider** — Direct access to Gemini models through Google's AI Studio API. Includes automatic models.dev registry integration for real-time context length detection across any provider. ([#5577](https://github.com/NousResearch/hermes-agent/pull/5577))
- **Inactivity-Based Agent Timeouts** — Gateway and cron timeouts now track actual tool activity instead of wall-clock time. Long-running tasks that are actively working will never be killed — only truly idle agents time out. ([#5389](https://github.com/NousResearch/hermes-agent/pull/5389), [#5440](https://github.com/NousResearch/hermes-agent/pull/5440))
- **Approval Buttons on Slack & Telegram** — Dangerous command approval via native platform buttons instead of typing `/approve`. Slack gets thread context preservation; Telegram gets emoji reactions for approval status. ([#5890](https://github.com/NousResearch/hermes-agent/pull/5890), [#5975](https://github.com/NousResearch/hermes-agent/pull/5975))
- **MCP OAuth 2.1 PKCE + OSV Malware Scanning** — Full standards-compliant OAuth for MCP server authentication, plus automatic malware scanning of MCP extension packages via the OSV vulnerability database. ([#5420](https://github.com/NousResearch/hermes-agent/pull/5420), [#5305](https://github.com/NousResearch/hermes-agent/pull/5305))
- **Centralized Logging & Config Validation** — Structured logging to `~/.hermes/logs/` (agent.log + errors.log) with the `hermes logs` command for tailing and filtering. Config structure validation catches malformed YAML at startup before it causes cryptic failures. ([#5430](https://github.com/NousResearch/hermes-agent/pull/5430), [#5426](https://github.com/NousResearch/hermes-agent/pull/5426))
- **Plugin System Expansion** — Plugins can now register CLI subcommands, receive request-scoped API hooks with correlation IDs, prompt for required env vars during install, and hook into session lifecycle events (finalize/reset). ([#5295](https://github.com/NousResearch/hermes-agent/pull/5295), [#5427](https://github.com/NousResearch/hermes-agent/pull/5427), [#5470](https://github.com/NousResearch/hermes-agent/pull/5470), [#6129](https://github.com/NousResearch/hermes-agent/pull/6129))
- **Matrix Tier 1 & Platform Hardening** — Matrix gets reactions, read receipts, rich formatting, and room management. Discord adds channel controls and ignored channels. Signal gets full MEDIA: tag delivery. Mattermost gets file attachments. Comprehensive reliability fixes across all platforms. ([#5275](https://github.com/NousResearch/hermes-agent/pull/5275), [#5975](https://github.com/NousResearch/hermes-agent/pull/5975), [#5602](https://github.com/NousResearch/hermes-agent/pull/5602))
- **Security Hardening Pass** — Consolidated SSRF protections, timing attack mitigations, tar traversal prevention, credential leakage guards, cron path traversal hardening, and cross-session isolation. Terminal workdir sanitization across all backends. ([#5944](https://github.com/NousResearch/hermes-agent/pull/5944), [#5613](https://github.com/NousResearch/hermes-agent/pull/5613), [#5629](https://github.com/NousResearch/hermes-agent/pull/5629))
---
## 🏗️ Core Agent & Architecture
### Provider & Model Support
- **Native Google AI Studio (Gemini) provider** with models.dev integration for automatic context length detection ([#5577](https://github.com/NousResearch/hermes-agent/pull/5577))
- **`/model` command — full provider+model system overhaul** — live switching across CLI and all gateway platforms with aggregator-aware resolution ([#5181](https://github.com/NousResearch/hermes-agent/pull/5181))
- **Interactive model picker for Telegram and Discord** — inline button-based model selection ([#5742](https://github.com/NousResearch/hermes-agent/pull/5742))
- **Nous Portal free-tier model gating** with pricing display in model selection ([#5880](https://github.com/NousResearch/hermes-agent/pull/5880))
- **Model pricing display** for OpenRouter and Nous Portal providers ([#5416](https://github.com/NousResearch/hermes-agent/pull/5416))
- **xAI (Grok) prompt caching** via `x-grok-conv-id` header ([#5604](https://github.com/NousResearch/hermes-agent/pull/5604))
- **Grok added to tool-use enforcement models** for direct xAI usage ([#5595](https://github.com/NousResearch/hermes-agent/pull/5595))
- **MiniMax TTS provider** (speech-2.8) ([#4963](https://github.com/NousResearch/hermes-agent/pull/4963))
- **Non-agentic model warning** — warns users when loading Hermes LLM models not designed for tool use ([#5378](https://github.com/NousResearch/hermes-agent/pull/5378))
- **Ollama Cloud auth, /model switch persistence**, and alias tab completion ([#5269](https://github.com/NousResearch/hermes-agent/pull/5269))
- **Preserve dots in OpenCode Go model names** (minimax-m2.7, glm-4.5, kimi-k2.5) ([#5597](https://github.com/NousResearch/hermes-agent/pull/5597))
- **MiniMax models 404 fix** — strip /v1 from Anthropic base URL for OpenCode Go ([#4918](https://github.com/NousResearch/hermes-agent/pull/4918))
- **Provider credential reset windows** honored in pooled failover ([#5188](https://github.com/NousResearch/hermes-agent/pull/5188))
- **OAuth token sync** between credential pool and credentials file ([#4981](https://github.com/NousResearch/hermes-agent/pull/4981))
- **Stale OAuth credentials** no longer block OpenRouter users on auto-detect ([#5746](https://github.com/NousResearch/hermes-agent/pull/5746))
- **Codex OAuth credential pool disconnect** + expired token import fix ([#5681](https://github.com/NousResearch/hermes-agent/pull/5681))
- **Codex pool entry sync** from `~/.codex/auth.json` on exhaustion — @GratefulDave ([#5610](https://github.com/NousResearch/hermes-agent/pull/5610))
- **Auxiliary client payment fallback** — retry with next provider on 402 ([#5599](https://github.com/NousResearch/hermes-agent/pull/5599))
- **Auxiliary client resolves named custom providers** and 'main' alias ([#5978](https://github.com/NousResearch/hermes-agent/pull/5978))
- **Use mimo-v2-pro** for non-vision auxiliary tasks on Nous free tier ([#6018](https://github.com/NousResearch/hermes-agent/pull/6018))
- **Vision auto-detection** tries main provider first ([#6041](https://github.com/NousResearch/hermes-agent/pull/6041))
- **Provider re-ordering and Quick Install** — @austinpickett ([#4664](https://github.com/NousResearch/hermes-agent/pull/4664))
- **Nous OAuth access_token** no longer used as inference API key — @SHL0MS ([#5564](https://github.com/NousResearch/hermes-agent/pull/5564))
- **HERMES_PORTAL_BASE_URL env var** respected during Nous login — @benbarclay ([#5745](https://github.com/NousResearch/hermes-agent/pull/5745))
- **Env var overrides** for Nous portal/inference URLs ([#5419](https://github.com/NousResearch/hermes-agent/pull/5419))
- **Z.AI endpoint auto-detect** via probe and cache ([#5763](https://github.com/NousResearch/hermes-agent/pull/5763))
- **MiniMax context lengths, model catalog, thinking guard, aux model, and config base_url** corrections ([#6082](https://github.com/NousResearch/hermes-agent/pull/6082))
- **Community provider/model resolution fixes** — salvaged 4 community PRs + MiniMax aux URL ([#5983](https://github.com/NousResearch/hermes-agent/pull/5983))
### Agent Loop & Conversation
- **Self-optimized GPT/Codex tool-use guidance** via automated behavioral benchmarking — agent self-diagnosed and patched 5 failure modes ([#6120](https://github.com/NousResearch/hermes-agent/pull/6120))
- **GPT/Codex execution discipline guidance** in system prompts ([#5414](https://github.com/NousResearch/hermes-agent/pull/5414))
- **Thinking-only prefill continuation** for structured reasoning responses ([#5931](https://github.com/NousResearch/hermes-agent/pull/5931))
- **Accept reasoning-only responses** without retries — set content to "(empty)" instead of infinite retry ([#5278](https://github.com/NousResearch/hermes-agent/pull/5278))
- **Jittered retry backoff** — exponential backoff with jitter for API retries ([#6048](https://github.com/NousResearch/hermes-agent/pull/6048))
- **Smart thinking block signature management** — preserve and manage Anthropic thinking signatures across turns ([#6112](https://github.com/NousResearch/hermes-agent/pull/6112))
- **Coerce tool call arguments** to match JSON Schema types — fixes models that send strings instead of numbers/booleans ([#5265](https://github.com/NousResearch/hermes-agent/pull/5265))
- **Save oversized tool results to file** instead of destructive truncation ([#5210](https://github.com/NousResearch/hermes-agent/pull/5210))
- **Sandbox-aware tool result persistence** ([#6085](https://github.com/NousResearch/hermes-agent/pull/6085))
- **Streaming fallback** improved after edit failures ([#6110](https://github.com/NousResearch/hermes-agent/pull/6110))
- **Codex empty-output gaps** covered in fallback + normalizer + auxiliary client ([#5724](https://github.com/NousResearch/hermes-agent/pull/5724), [#5730](https://github.com/NousResearch/hermes-agent/pull/5730), [#5734](https://github.com/NousResearch/hermes-agent/pull/5734))
- **Codex stream output backfill** from output_item.done events ([#5689](https://github.com/NousResearch/hermes-agent/pull/5689))
- **Stream consumer creates new message** after tool boundaries ([#5739](https://github.com/NousResearch/hermes-agent/pull/5739))
- **Codex validation aligned** with normalization for empty stream output ([#5940](https://github.com/NousResearch/hermes-agent/pull/5940))
- **Bridge tool-calls** in copilot-acp adapter ([#5460](https://github.com/NousResearch/hermes-agent/pull/5460))
- **Filter transcript-only roles** from chat-completions payload ([#4880](https://github.com/NousResearch/hermes-agent/pull/4880))
- **Context compaction failures fixed** on temperature-restricted models — @MadKangYu ([#5608](https://github.com/NousResearch/hermes-agent/pull/5608))
- **Sanitize tool_calls for all strict APIs** (Fireworks, Mistral, etc.) — @lumethegreat ([#5183](https://github.com/NousResearch/hermes-agent/pull/5183))
### Memory & Sessions
- **Supermemory memory provider** — new memory plugin with multi-container, search_mode, identity template, and env var override ([#5737](https://github.com/NousResearch/hermes-agent/pull/5737), [#5933](https://github.com/NousResearch/hermes-agent/pull/5933))
- **Shared thread sessions** by default — multi-user thread support across gateway platforms ([#5391](https://github.com/NousResearch/hermes-agent/pull/5391))
- **Subagent sessions linked to parent** and hidden from session list ([#5309](https://github.com/NousResearch/hermes-agent/pull/5309))
- **Profile-scoped memory isolation** and clone support ([#4845](https://github.com/NousResearch/hermes-agent/pull/4845))
- **Thread gateway user_id to memory plugins** for per-user scoping ([#5895](https://github.com/NousResearch/hermes-agent/pull/5895))
- **Honcho plugin drift overhaul** + plugin CLI registration system ([#5295](https://github.com/NousResearch/hermes-agent/pull/5295))
- **Honcho holographic prompt and trust score** rendering preserved ([#4872](https://github.com/NousResearch/hermes-agent/pull/4872))
- **Honcho doctor fix** — use recall_mode instead of memory_mode — @techguysimon ([#5645](https://github.com/NousResearch/hermes-agent/pull/5645))
- **RetainDB** — API routes, write queue, dialectic, agent model, file tools fixes ([#5461](https://github.com/NousResearch/hermes-agent/pull/5461))
- **Hindsight memory plugin overhaul** + memory setup wizard fixes ([#5094](https://github.com/NousResearch/hermes-agent/pull/5094))
- **mem0 API v2 compat**, prefetch context fencing, secret redaction ([#5423](https://github.com/NousResearch/hermes-agent/pull/5423))
- **mem0 env vars merged** with mem0.json instead of either/or ([#4939](https://github.com/NousResearch/hermes-agent/pull/4939))
- **Clean user message** used for all memory provider operations ([#4940](https://github.com/NousResearch/hermes-agent/pull/4940))
- **Silent memory flush failure** on /new and /resume fixed — @ryanautomated ([#5640](https://github.com/NousResearch/hermes-agent/pull/5640))
- **OpenViking atexit safety net** for session commit ([#5664](https://github.com/NousResearch/hermes-agent/pull/5664))
- **OpenViking tenant-scoping headers** for multi-tenant servers ([#4936](https://github.com/NousResearch/hermes-agent/pull/4936))
- **ByteRover brv query** runs synchronously before LLM call ([#4831](https://github.com/NousResearch/hermes-agent/pull/4831))
---
## 📱 Messaging Platforms (Gateway)
### Gateway Core
- **Inactivity-based agent timeout** — replaces wall-clock timeout with smart activity tracking; long-running active tasks never killed ([#5389](https://github.com/NousResearch/hermes-agent/pull/5389))
- **Approval buttons for Slack & Telegram** + Slack thread context preservation ([#5890](https://github.com/NousResearch/hermes-agent/pull/5890))
- **Live-stream /update output** + forward interactive prompts to user ([#5180](https://github.com/NousResearch/hermes-agent/pull/5180))
- **Infinite timeout support** + periodic notifications + actionable error messages ([#4959](https://github.com/NousResearch/hermes-agent/pull/4959))
- **Duplicate message prevention** — gateway dedup + partial stream guard ([#4878](https://github.com/NousResearch/hermes-agent/pull/4878))
- **Webhook delivery_info persistence** + full session id in /status ([#5942](https://github.com/NousResearch/hermes-agent/pull/5942))
- **Tool preview truncation** respects tool_preview_length in all/new progress modes ([#5937](https://github.com/NousResearch/hermes-agent/pull/5937))
- **Short preview truncation** restored for all/new tool progress modes ([#4935](https://github.com/NousResearch/hermes-agent/pull/4935))
- **Update-pending state** written atomically to prevent corruption ([#4923](https://github.com/NousResearch/hermes-agent/pull/4923))
- **Approval session key isolated** per turn ([#4884](https://github.com/NousResearch/hermes-agent/pull/4884))
- **Active-session guard bypass** for /approve, /deny, /stop, /new ([#4926](https://github.com/NousResearch/hermes-agent/pull/4926), [#5765](https://github.com/NousResearch/hermes-agent/pull/5765))
- **Typing indicator paused** during approval waits ([#5893](https://github.com/NousResearch/hermes-agent/pull/5893))
- **Caption check** uses exact line-by-line match instead of substring (all platforms) ([#5939](https://github.com/NousResearch/hermes-agent/pull/5939))
- **MEDIA: tags stripped** from streamed gateway messages ([#5152](https://github.com/NousResearch/hermes-agent/pull/5152))
- **MEDIA: tags extracted** from cron delivery before sending ([#5598](https://github.com/NousResearch/hermes-agent/pull/5598))
- **Profile-aware service units** + voice transcription cleanup ([#5972](https://github.com/NousResearch/hermes-agent/pull/5972))
- **Thread-safe PairingStore** with atomic writes — @CharlieKerfoot ([#5656](https://github.com/NousResearch/hermes-agent/pull/5656))
- **Sanitize media URLs** in base platform logs — @WAXLYY ([#5631](https://github.com/NousResearch/hermes-agent/pull/5631))
- **Reduce Telegram fallback IP activation log noise** — @MadKangYu ([#5615](https://github.com/NousResearch/hermes-agent/pull/5615))
- **Cron static method wrappers** to prevent self-binding ([#5299](https://github.com/NousResearch/hermes-agent/pull/5299))
- **Stale 'hermes login' replaced** with 'hermes auth' + credential removal re-seeding fix ([#5670](https://github.com/NousResearch/hermes-agent/pull/5670))
### Telegram
- **Group topics skill binding** for supergroup forum topics ([#4886](https://github.com/NousResearch/hermes-agent/pull/4886))
- **Emoji reactions** for approval status and notifications ([#5975](https://github.com/NousResearch/hermes-agent/pull/5975))
- **Duplicate message delivery prevented** on send timeout ([#5153](https://github.com/NousResearch/hermes-agent/pull/5153))
- **Command names sanitized** to strip invalid characters ([#5596](https://github.com/NousResearch/hermes-agent/pull/5596))
- **Per-platform disabled skills** respected in Telegram menu and gateway dispatch ([#4799](https://github.com/NousResearch/hermes-agent/pull/4799))
- **/approve and /deny** routed through running-agent guard ([#4798](https://github.com/NousResearch/hermes-agent/pull/4798))
### Discord
- **Channel controls** — ignored_channels and no_thread_channels config options ([#5975](https://github.com/NousResearch/hermes-agent/pull/5975))
- **Skills registered as native slash commands** via shared gateway logic ([#5603](https://github.com/NousResearch/hermes-agent/pull/5603))
- **/approve, /deny, /queue, /background, /btw** registered as native slash commands ([#4800](https://github.com/NousResearch/hermes-agent/pull/4800), [#5477](https://github.com/NousResearch/hermes-agent/pull/5477))
- **Unnecessary members intent** removed on startup + token lock leak fix ([#5302](https://github.com/NousResearch/hermes-agent/pull/5302))
### Slack
- **Thread engagement** — auto-respond in bot-started and mentioned threads ([#5897](https://github.com/NousResearch/hermes-agent/pull/5897))
- **mrkdwn in edit_message** + thread replies without @mentions ([#5733](https://github.com/NousResearch/hermes-agent/pull/5733))
### Matrix
- **Tier 1 feature parity** — reactions, read receipts, rich formatting, room management ([#5275](https://github.com/NousResearch/hermes-agent/pull/5275))
- **MATRIX_REQUIRE_MENTION and MATRIX_AUTO_THREAD** support ([#5106](https://github.com/NousResearch/hermes-agent/pull/5106))
- **Comprehensive reliability** — encrypted media, auth recovery, cron E2EE, Synapse compat ([#5271](https://github.com/NousResearch/hermes-agent/pull/5271))
- **CJK input, E2EE, and reconnect** fixes ([#5665](https://github.com/NousResearch/hermes-agent/pull/5665))
### Signal
- **Full MEDIA: tag delivery** — send_image_file, send_voice, and send_video implemented ([#5602](https://github.com/NousResearch/hermes-agent/pull/5602))
### Mattermost
- **File attachments** — set message type to DOCUMENT when post has file attachments — @nericervin ([#5609](https://github.com/NousResearch/hermes-agent/pull/5609))
### Feishu
- **Interactive card approval buttons** ([#6043](https://github.com/NousResearch/hermes-agent/pull/6043))
- **Reconnect and ACL** fixes ([#5665](https://github.com/NousResearch/hermes-agent/pull/5665))
### Webhooks
- **`{__raw__}` template token** and thread_id passthrough for forum topics ([#5662](https://github.com/NousResearch/hermes-agent/pull/5662))
---
## 🖥️ CLI & User Experience
### Interactive CLI
- **Defer response content** until reasoning block completes ([#5773](https://github.com/NousResearch/hermes-agent/pull/5773))
- **Ghost status-bar lines cleared** on terminal resize ([#4960](https://github.com/NousResearch/hermes-agent/pull/4960))
- **Normalise \r\n and \r line endings** in pasted text ([#4849](https://github.com/NousResearch/hermes-agent/pull/4849))
- **ChatConsole errors, curses scroll, skin-aware banner, git state** banner fixes ([#5974](https://github.com/NousResearch/hermes-agent/pull/5974))
- **Native Windows image paste** support ([#5917](https://github.com/NousResearch/hermes-agent/pull/5917))
- **--yolo and other flags** no longer silently dropped when placed before 'chat' subcommand ([#5145](https://github.com/NousResearch/hermes-agent/pull/5145))
### Setup & Configuration
- **Config structure validation** — detect malformed YAML at startup with actionable error messages ([#5426](https://github.com/NousResearch/hermes-agent/pull/5426))
- **Centralized logging** to `~/.hermes/logs/` — agent.log (INFO+), errors.log (WARNING+) with `hermes logs` command ([#5430](https://github.com/NousResearch/hermes-agent/pull/5430))
- **Docs links added** to setup wizard sections ([#5283](https://github.com/NousResearch/hermes-agent/pull/5283))
- **Doctor diagnostics** — sync provider checks, config migration, WAL and mem0 diagnostics ([#5077](https://github.com/NousResearch/hermes-agent/pull/5077))
- **Timeout debug logging** and user-facing diagnostics improved ([#5370](https://github.com/NousResearch/hermes-agent/pull/5370))
- **Reasoning effort unified** to config.yaml only ([#6118](https://github.com/NousResearch/hermes-agent/pull/6118))
- **Permanent command allowlist** loaded on startup ([#5076](https://github.com/NousResearch/hermes-agent/pull/5076))
- **`hermes auth remove`** now clears env-seeded credentials permanently ([#5285](https://github.com/NousResearch/hermes-agent/pull/5285))
- **Bundled skills synced to all profiles** during update ([#5795](https://github.com/NousResearch/hermes-agent/pull/5795))
- **`hermes update` no longer kills** freshly-restarted gateway service ([#5448](https://github.com/NousResearch/hermes-agent/pull/5448))
- **Subprocess.run() timeouts** added to all gateway CLI commands ([#5424](https://github.com/NousResearch/hermes-agent/pull/5424))
- **Actionable error message** when Codex refresh token is reused — @tymrtn ([#5612](https://github.com/NousResearch/hermes-agent/pull/5612))
- **Google-workspace skill scripts** can now run directly — @xinbenlv ([#5624](https://github.com/NousResearch/hermes-agent/pull/5624))
### Cron System
- **Inactivity-based cron timeout** — replaces wall-clock; active tasks run indefinitely ([#5440](https://github.com/NousResearch/hermes-agent/pull/5440))
- **Pre-run script injection** for data collection and change detection ([#5082](https://github.com/NousResearch/hermes-agent/pull/5082))
- **Delivery failure tracking** in job status ([#6042](https://github.com/NousResearch/hermes-agent/pull/6042))
- **Delivery guidance** in cron prompts — stops send_message thrashing ([#5444](https://github.com/NousResearch/hermes-agent/pull/5444))
- **MEDIA files delivered** as native platform attachments ([#5921](https://github.com/NousResearch/hermes-agent/pull/5921))
- **[SILENT] suppression** works anywhere in response — @auspic7 ([#5654](https://github.com/NousResearch/hermes-agent/pull/5654))
- **Cron path traversal** hardening ([#5147](https://github.com/NousResearch/hermes-agent/pull/5147))
---
## 🔧 Tool System
### Terminal & Execution
- **Execute_code on remote backends** — code execution now works on Docker, SSH, Modal, and other remote terminal backends ([#5088](https://github.com/NousResearch/hermes-agent/pull/5088))
- **Exit code context** for common CLI tools in terminal results — helps agent understand what went wrong ([#5144](https://github.com/NousResearch/hermes-agent/pull/5144))
- **Progressive subdirectory hint discovery** — agent learns project structure as it navigates ([#5291](https://github.com/NousResearch/hermes-agent/pull/5291))
- **notify_on_complete for background processes** — get notified when long-running tasks finish ([#5779](https://github.com/NousResearch/hermes-agent/pull/5779))
- **Docker env config** — explicit container environment variables via docker_env config ([#4738](https://github.com/NousResearch/hermes-agent/pull/4738))
- **Approval metadata included** in terminal tool results ([#5141](https://github.com/NousResearch/hermes-agent/pull/5141))
- **Workdir parameter sanitized** in terminal tool across all backends ([#5629](https://github.com/NousResearch/hermes-agent/pull/5629))
- **Detached process crash recovery** state corrected ([#6101](https://github.com/NousResearch/hermes-agent/pull/6101))
- **Agent-browser paths with spaces** preserved — @Vasanthdev2004 ([#6077](https://github.com/NousResearch/hermes-agent/pull/6077))
- **Portable base64 encoding** for image reading on macOS — @CharlieKerfoot ([#5657](https://github.com/NousResearch/hermes-agent/pull/5657))
### Browser
- **Switch managed browser provider** from Browserbase to Browser Use — @benbarclay ([#5750](https://github.com/NousResearch/hermes-agent/pull/5750))
- **Firecrawl cloud browser** provider — @alt-glitch ([#5628](https://github.com/NousResearch/hermes-agent/pull/5628))
- **JS evaluation** via browser_console expression parameter ([#5303](https://github.com/NousResearch/hermes-agent/pull/5303))
- **Windows browser** fixes ([#5665](https://github.com/NousResearch/hermes-agent/pull/5665))
### MCP
- **MCP OAuth 2.1 PKCE** — full standards-compliant OAuth client support ([#5420](https://github.com/NousResearch/hermes-agent/pull/5420))
- **OSV malware check** for MCP extension packages ([#5305](https://github.com/NousResearch/hermes-agent/pull/5305))
- **Prefer structuredContent over text** + no_mcp sentinel ([#5979](https://github.com/NousResearch/hermes-agent/pull/5979))
- **Unknown toolsets warning suppressed** for MCP server names ([#5279](https://github.com/NousResearch/hermes-agent/pull/5279))
### Web & Files
- **.zip document support** + auto-mount cache dirs into remote backends ([#4846](https://github.com/NousResearch/hermes-agent/pull/4846))
- **Redact query secrets** in send_message errors — @WAXLYY ([#5650](https://github.com/NousResearch/hermes-agent/pull/5650))
### Delegation
- **Credential pool sharing** + workspace path hints for subagents ([#5748](https://github.com/NousResearch/hermes-agent/pull/5748))
### ACP (VS Code / Zed / JetBrains)
- **Aggregate ACP improvements** — auth compat, protocol fixes, command ads, delegation, SSE events ([#5292](https://github.com/NousResearch/hermes-agent/pull/5292))
---
## 🧩 Skills Ecosystem
### Skills System
- **Skill config interface** — skills can declare required config.yaml settings, prompted during setup, injected at load time ([#5635](https://github.com/NousResearch/hermes-agent/pull/5635))
- **Plugin CLI registration system** — plugins register their own CLI subcommands without touching main.py ([#5295](https://github.com/NousResearch/hermes-agent/pull/5295))
- **Request-scoped API hooks** with tool call correlation IDs for plugins ([#5427](https://github.com/NousResearch/hermes-agent/pull/5427))
- **Session lifecycle hooks** — on_session_finalize and on_session_reset for CLI + gateway ([#6129](https://github.com/NousResearch/hermes-agent/pull/6129))
- **Prompt for required env vars** during plugin install — @kshitijk4poor ([#5470](https://github.com/NousResearch/hermes-agent/pull/5470))
- **Plugin name validation** — reject names that resolve to plugins root ([#5368](https://github.com/NousResearch/hermes-agent/pull/5368))
- **pre_llm_call plugin context** moved to user message to preserve prompt cache ([#5146](https://github.com/NousResearch/hermes-agent/pull/5146))
### New & Updated Skills
- **popular-web-designs** — 54 production website design systems ([#5194](https://github.com/NousResearch/hermes-agent/pull/5194))
- **p5js creative coding** — @SHL0MS ([#5600](https://github.com/NousResearch/hermes-agent/pull/5600))
- **manim-video** — mathematical and technical animations — @SHL0MS ([#4930](https://github.com/NousResearch/hermes-agent/pull/4930))
- **llm-wiki** — Karpathy's LLM Wiki skill ([#5635](https://github.com/NousResearch/hermes-agent/pull/5635))
- **gitnexus-explorer** — codebase indexing and knowledge serving ([#5208](https://github.com/NousResearch/hermes-agent/pull/5208))
- **research-paper-writing** — AI-Scientist & GPT-Researcher patterns — @SHL0MS ([#5421](https://github.com/NousResearch/hermes-agent/pull/5421))
- **blogwatcher** updated to JulienTant's fork ([#5759](https://github.com/NousResearch/hermes-agent/pull/5759))
- **claude-code skill** comprehensive rewrite v2.0 + v2.2 ([#5155](https://github.com/NousResearch/hermes-agent/pull/5155), [#5158](https://github.com/NousResearch/hermes-agent/pull/5158))
- **Code verification skills** consolidated into one ([#4854](https://github.com/NousResearch/hermes-agent/pull/4854))
- **Manim CE reference docs** expanded — geometry, animations, LaTeX — @leotrs ([#5791](https://github.com/NousResearch/hermes-agent/pull/5791))
- **Manim-video references** — design thinking, updaters, paper explainer, decorations, production quality — @SHL0MS ([#5588](https://github.com/NousResearch/hermes-agent/pull/5588), [#5408](https://github.com/NousResearch/hermes-agent/pull/5408))
---
## 🔒 Security & Reliability
### Security Hardening
- **Consolidated security** — SSRF protections, timing attack mitigations, tar traversal prevention, credential leakage guards ([#5944](https://github.com/NousResearch/hermes-agent/pull/5944))
- **Cross-session isolation** + cron path traversal hardening ([#5613](https://github.com/NousResearch/hermes-agent/pull/5613))
- **Workdir parameter sanitized** in terminal tool across all backends ([#5629](https://github.com/NousResearch/hermes-agent/pull/5629))
- **Approval 'once' session escalation** prevented + cron delivery platform validation ([#5280](https://github.com/NousResearch/hermes-agent/pull/5280))
- **Profile-scoped Google Workspace OAuth tokens** protected ([#4910](https://github.com/NousResearch/hermes-agent/pull/4910))
### Reliability
- **Aggressive worktree and branch cleanup** to prevent accumulation ([#6134](https://github.com/NousResearch/hermes-agent/pull/6134))
- **O(n²) catastrophic backtracking** in redact regex fixed — 100x improvement on large outputs ([#4962](https://github.com/NousResearch/hermes-agent/pull/4962))
- **Runtime stability fixes** across core, web, delegate, and browser tools ([#4843](https://github.com/NousResearch/hermes-agent/pull/4843))
- **API server streaming fix** + conversation history support ([#5977](https://github.com/NousResearch/hermes-agent/pull/5977))
- **OpenViking API endpoint paths** and response parsing corrected ([#5078](https://github.com/NousResearch/hermes-agent/pull/5078))
---
## 🐛 Notable Bug Fixes
- **9 community bugfixes salvaged** — gateway, cron, deps, macOS launchd in one batch ([#5288](https://github.com/NousResearch/hermes-agent/pull/5288))
- **Batch core bug fixes** — model config, session reset, alias fallback, launchctl, delegation, atomic writes ([#5630](https://github.com/NousResearch/hermes-agent/pull/5630))
- **Batch gateway/platform fixes** — matrix E2EE, CJK input, Windows browser, Feishu reconnect + ACL ([#5665](https://github.com/NousResearch/hermes-agent/pull/5665))
- **Stale test skips removed**, regex backtracking, file search bug, and test flakiness ([#4969](https://github.com/NousResearch/hermes-agent/pull/4969))
- **Nix flake** — read version, regen uv.lock, add hermes_logging — @alt-glitch ([#5651](https://github.com/NousResearch/hermes-agent/pull/5651))
- **Lowercase variable redaction** regression tests ([#5185](https://github.com/NousResearch/hermes-agent/pull/5185))
---
## 🧪 Testing
- **57 failing CI tests repaired** across 14 files ([#5823](https://github.com/NousResearch/hermes-agent/pull/5823))
- **Test suite re-architecture** + CI failure fixes — @alt-glitch ([#5946](https://github.com/NousResearch/hermes-agent/pull/5946))
- **Codebase-wide lint cleanup** — unused imports, dead code, and inefficient patterns ([#5821](https://github.com/NousResearch/hermes-agent/pull/5821))
- **browser_close tool removed** — auto-cleanup handles it ([#5792](https://github.com/NousResearch/hermes-agent/pull/5792))
---
## 📚 Documentation
- **Comprehensive documentation audit** — fix stale info, expand thin pages, add depth ([#5393](https://github.com/NousResearch/hermes-agent/pull/5393))
- **40+ discrepancies fixed** between documentation and codebase ([#5818](https://github.com/NousResearch/hermes-agent/pull/5818))
- **13 features documented** from last week's PRs ([#5815](https://github.com/NousResearch/hermes-agent/pull/5815))
- **Guides section overhaul** — fix existing + add 3 new tutorials ([#5735](https://github.com/NousResearch/hermes-agent/pull/5735))
- **Salvaged 4 docs PRs** — docker setup, post-update validation, local LLM guide, signal-cli install ([#5727](https://github.com/NousResearch/hermes-agent/pull/5727))
- **Discord configuration reference** ([#5386](https://github.com/NousResearch/hermes-agent/pull/5386))
- **Community FAQ entries** for common workflows and troubleshooting ([#4797](https://github.com/NousResearch/hermes-agent/pull/4797))
- **WSL2 networking guide** for local model servers ([#5616](https://github.com/NousResearch/hermes-agent/pull/5616))
- **Honcho CLI reference** + plugin CLI registration docs ([#5308](https://github.com/NousResearch/hermes-agent/pull/5308))
- **Obsidian Headless setup** for servers in llm-wiki ([#5660](https://github.com/NousResearch/hermes-agent/pull/5660))
- **Hermes Mod visual skin editor** added to skins page ([#6095](https://github.com/NousResearch/hermes-agent/pull/6095))
---
## 👥 Contributors
### Core
- **@teknium1** — 179 PRs
### Top Community Contributors
- **@SHL0MS** (7 PRs) — p5js creative coding skill, manim-video skill + 5 reference expansions, research-paper-writing, Nous OAuth fix, manim font fix
- **@alt-glitch** (3 PRs) — Firecrawl cloud browser provider, test re-architecture + CI fixes, Nix flake fixes
- **@benbarclay** (2 PRs) — Browser Use managed provider switch, Nous portal base URL fix
- **@CharlieKerfoot** (2 PRs) — macOS portable base64 encoding, thread-safe PairingStore
- **@WAXLYY** (2 PRs) — send_message secret redaction, gateway media URL sanitization
- **@MadKangYu** (2 PRs) — Telegram log noise reduction, context compaction fix for temperature-restricted models
### All Contributors
@alt-glitch, @austinpickett, @auspic7, @benbarclay, @CharlieKerfoot, @GratefulDave, @kshitijk4poor, @leotrs, @lumethegreat, @MadKangYu, @nericervin, @ryanautomated, @SHL0MS, @techguysimon, @tymrtn, @Vasanthdev2004, @WAXLYY, @xinbenlv
---
**Full Changelog**: [v2026.4.3...v2026.4.8](https://github.com/NousResearch/hermes-agent/compare/v2026.4.3...v2026.4.8)

329
RELEASE_v0.9.0.md Normal file
View File

@@ -0,0 +1,329 @@
# Hermes Agent v0.9.0 (v2026.4.13)
**Release Date:** April 13, 2026
**Since v0.8.0:** 487 commits · 269 merged PRs · 167 resolved issues · 493 files changed · 63,281 insertions · 24 contributors
> The everywhere release — Hermes goes mobile with Termux/Android, adds iMessage and WeChat, ships Fast Mode for OpenAI and Anthropic, introduces background process monitoring, launches a local web dashboard for managing your agent, and delivers the deepest security hardening pass yet across 16 supported platforms.
---
## ✨ Highlights
- **Local Web Dashboard** — A new browser-based dashboard for managing your Hermes Agent locally. Configure settings, monitor sessions, browse skills, and manage your gateway — all from a clean web interface without touching config files or the terminal. The easiest way to get started with Hermes.
- **Fast Mode (`/fast`)** — Priority processing for OpenAI and Anthropic models. Toggle `/fast` to route through priority queues for significantly lower latency on supported models (GPT-5.4, Codex, Claude). Expands across all OpenAI Priority Processing models and Anthropic's fast tier. ([#6875](https://github.com/NousResearch/hermes-agent/pull/6875), [#6960](https://github.com/NousResearch/hermes-agent/pull/6960), [#7037](https://github.com/NousResearch/hermes-agent/pull/7037))
- **iMessage via BlueBubbles** — Full iMessage integration through BlueBubbles, bringing Hermes to Apple's messaging ecosystem. Auto-webhook registration, setup wizard integration, and crash resilience. ([#6437](https://github.com/NousResearch/hermes-agent/pull/6437), [#6460](https://github.com/NousResearch/hermes-agent/pull/6460), [#6494](https://github.com/NousResearch/hermes-agent/pull/6494))
- **WeChat (Weixin) & WeCom Callback Mode** — Native WeChat support via iLink Bot API and a new WeCom callback-mode adapter for self-built enterprise apps. Streaming cursor, media uploads, markdown link handling, and atomic state persistence. Hermes now covers the Chinese messaging ecosystem end-to-end. ([#7166](https://github.com/NousResearch/hermes-agent/pull/7166), [#7943](https://github.com/NousResearch/hermes-agent/pull/7943))
- **Termux / Android Support** — Run Hermes natively on Android via Termux. Adapted install paths, TUI optimizations for mobile screens, voice backend support, and the `/image` command work on-device. ([#6834](https://github.com/NousResearch/hermes-agent/pull/6834))
- **Background Process Monitoring (`watch_patterns`)** — Set patterns to watch for in background process output and get notified in real-time when they match. Monitor for errors, wait for specific events ("listening on port"), or watch build logs — all without polling. ([#7635](https://github.com/NousResearch/hermes-agent/pull/7635))
- **Native xAI & Xiaomi MiMo Providers** — First-class provider support for xAI (Grok) and Xiaomi MiMo, with direct API access, model catalogs, and setup wizard integration. Plus Qwen OAuth with portal request support. ([#7372](https://github.com/NousResearch/hermes-agent/pull/7372), [#7855](https://github.com/NousResearch/hermes-agent/pull/7855))
- **Pluggable Context Engine** — Context management is now a pluggable slot via `hermes plugins`. Swap in custom context engines that control what the agent sees each turn — filtering, summarization, or domain-specific context injection. ([#7464](https://github.com/NousResearch/hermes-agent/pull/7464))
- **Unified Proxy Support** — SOCKS proxy, `DISCORD_PROXY`, and system proxy auto-detection across all gateway platforms. Hermes behind corporate firewalls just works. ([#6814](https://github.com/NousResearch/hermes-agent/pull/6814))
- **Comprehensive Security Hardening** — Path traversal protection in checkpoint manager, shell injection neutralization in sandbox writes, SSRF redirect guards in Slack image uploads, Twilio webhook signature validation (SMS RCE fix), API server auth enforcement, git argument injection prevention, and approval button authorization. ([#7933](https://github.com/NousResearch/hermes-agent/pull/7933), [#7944](https://github.com/NousResearch/hermes-agent/pull/7944), [#7940](https://github.com/NousResearch/hermes-agent/pull/7940), [#7151](https://github.com/NousResearch/hermes-agent/pull/7151), [#7156](https://github.com/NousResearch/hermes-agent/pull/7156))
- **`hermes backup` & `hermes import`** — Full backup and restore of your Hermes configuration, sessions, skills, and memory. Migrate between machines or create snapshots before major changes. ([#7997](https://github.com/NousResearch/hermes-agent/pull/7997))
- **16 Supported Platforms** — With BlueBubbles (iMessage) and WeChat joining Telegram, Discord, Slack, WhatsApp, Signal, Matrix, Email, SMS, DingTalk, Feishu, WeCom, Mattermost, Home Assistant, and Webhooks, Hermes now runs on 16 messaging platforms out of the box.
- **`/debug` & `hermes debug share`** — New debugging toolkit: `/debug` slash command across all platforms for quick diagnostics, plus `hermes debug share` to upload a full debug report to a pastebin for easy sharing when troubleshooting. ([#8681](https://github.com/NousResearch/hermes-agent/pull/8681))
---
## 🏗️ Core Agent & Architecture
### Provider & Model Support
- **Native xAI (Grok) provider** with direct API access and model catalog ([#7372](https://github.com/NousResearch/hermes-agent/pull/7372))
- **Xiaomi MiMo as first-class provider** — setup wizard, model catalog, empty response recovery ([#7855](https://github.com/NousResearch/hermes-agent/pull/7855))
- **Qwen OAuth provider** with portal request support ([#6282](https://github.com/NousResearch/hermes-agent/pull/6282))
- **Fast Mode** — `/fast` toggle for OpenAI Priority Processing + Anthropic fast tier ([#6875](https://github.com/NousResearch/hermes-agent/pull/6875), [#6960](https://github.com/NousResearch/hermes-agent/pull/6960), [#7037](https://github.com/NousResearch/hermes-agent/pull/7037))
- **Structured API error classification** for smart failover decisions ([#6514](https://github.com/NousResearch/hermes-agent/pull/6514))
- **Rate limit header capture** shown in `/usage` ([#6541](https://github.com/NousResearch/hermes-agent/pull/6541))
- **API server model name** derived from profile name ([#6857](https://github.com/NousResearch/hermes-agent/pull/6857))
- **Custom providers** now included in `/model` listings and resolution ([#7088](https://github.com/NousResearch/hermes-agent/pull/7088))
- **Fallback provider activation** on repeated empty responses with user-visible status ([#7505](https://github.com/NousResearch/hermes-agent/pull/7505))
- **OpenRouter variant tags** (`:free`, `:extended`, `:fast`) preserved during model switch ([#6383](https://github.com/NousResearch/hermes-agent/pull/6383))
- **Credential exhaustion TTL** reduced from 24 hours to 1 hour ([#6504](https://github.com/NousResearch/hermes-agent/pull/6504))
- **OAuth credential lifecycle** hardening — stale pool keys, auth.json sync, Codex CLI race fixes ([#6874](https://github.com/NousResearch/hermes-agent/pull/6874))
- Empty response recovery for reasoning models (MiMo, Qwen, GLM) ([#8609](https://github.com/NousResearch/hermes-agent/pull/8609))
- MiniMax context lengths, thinking guard, endpoint corrections ([#6082](https://github.com/NousResearch/hermes-agent/pull/6082), [#7126](https://github.com/NousResearch/hermes-agent/pull/7126))
- Z.AI endpoint auto-detect via probe and cache ([#5763](https://github.com/NousResearch/hermes-agent/pull/5763))
### Agent Loop & Conversation
- **Pluggable context engine slot** via `hermes plugins` ([#7464](https://github.com/NousResearch/hermes-agent/pull/7464))
- **Background process monitoring** — `watch_patterns` for real-time output alerts ([#7635](https://github.com/NousResearch/hermes-agent/pull/7635))
- **Improved context compression** — higher limits, tool tracking, degradation warnings, token-budget tail protection ([#6395](https://github.com/NousResearch/hermes-agent/pull/6395), [#6453](https://github.com/NousResearch/hermes-agent/pull/6453))
- **`/compress <focus>`** — guided compression with a focus topic ([#8017](https://github.com/NousResearch/hermes-agent/pull/8017))
- **Tiered context pressure warnings** with gateway dedup ([#6411](https://github.com/NousResearch/hermes-agent/pull/6411))
- **Staged inactivity warning** before timeout escalation ([#6387](https://github.com/NousResearch/hermes-agent/pull/6387))
- **Prevent agent from stopping mid-task** — compression floor, budget overhaul, activity tracking ([#7983](https://github.com/NousResearch/hermes-agent/pull/7983))
- **Propagate child activity to parent** during `delegate_task` ([#7295](https://github.com/NousResearch/hermes-agent/pull/7295))
- **Truncated streaming tool call detection** before execution ([#6847](https://github.com/NousResearch/hermes-agent/pull/6847))
- Empty response retry (3 attempts with nudge) ([#6488](https://github.com/NousResearch/hermes-agent/pull/6488))
- Adaptive streaming backoff + cursor strip to prevent message truncation ([#7683](https://github.com/NousResearch/hermes-agent/pull/7683))
- Compression uses live session model instead of stale persisted config ([#8258](https://github.com/NousResearch/hermes-agent/pull/8258))
- Strip `<thought>` tags from Gemma 4 responses ([#8562](https://github.com/NousResearch/hermes-agent/pull/8562))
- Prevent `<think>` in prose from suppressing response output ([#6968](https://github.com/NousResearch/hermes-agent/pull/6968))
- Turn-exit diagnostic logging to agent loop ([#6549](https://github.com/NousResearch/hermes-agent/pull/6549))
- Scope tool interrupt signal per-thread to prevent cross-session leaks ([#7930](https://github.com/NousResearch/hermes-agent/pull/7930))
### Memory & Sessions
- **Hindsight memory plugin** — feature parity, setup wizard, config improvements — @nicoloboschi ([#6428](https://github.com/NousResearch/hermes-agent/pull/6428))
- **Honcho** — opt-in `initOnSessionStart` for tools mode — @Kathie-yu ([#6995](https://github.com/NousResearch/hermes-agent/pull/6995))
- Orphan children instead of cascade-deleting in prune/delete ([#6513](https://github.com/NousResearch/hermes-agent/pull/6513))
- Doctor command only checks the active memory provider ([#6285](https://github.com/NousResearch/hermes-agent/pull/6285))
---
## 📱 Messaging Platforms (Gateway)
### New Platforms
- **BlueBubbles (iMessage)** — full adapter with auto-webhook registration, setup wizard, and crash resilience ([#6437](https://github.com/NousResearch/hermes-agent/pull/6437), [#6460](https://github.com/NousResearch/hermes-agent/pull/6460), [#6494](https://github.com/NousResearch/hermes-agent/pull/6494), [#7107](https://github.com/NousResearch/hermes-agent/pull/7107))
- **Weixin (WeChat)** — native support via iLink Bot API with streaming, media uploads, markdown links ([#7166](https://github.com/NousResearch/hermes-agent/pull/7166), [#8665](https://github.com/NousResearch/hermes-agent/pull/8665))
- **WeCom Callback Mode** — self-built enterprise app adapter with atomic state persistence ([#7943](https://github.com/NousResearch/hermes-agent/pull/7943), [#7928](https://github.com/NousResearch/hermes-agent/pull/7928))
### Discord
- **Allowed channels whitelist** config — @jarvis-phw ([#7044](https://github.com/NousResearch/hermes-agent/pull/7044))
- **Forum channel topic inheritance** in thread sessions — @hermes-agent-dhabibi ([#6377](https://github.com/NousResearch/hermes-agent/pull/6377))
- **DISCORD_REPLY_TO_MODE** setting ([#6333](https://github.com/NousResearch/hermes-agent/pull/6333))
- Accept `.log` attachments, raise document size limit — @kira-ariaki ([#6467](https://github.com/NousResearch/hermes-agent/pull/6467))
- Decouple readiness from slash sync ([#8016](https://github.com/NousResearch/hermes-agent/pull/8016))
### Slack
- **Consolidated Slack improvements** — 7 community PRs salvaged into one ([#6809](https://github.com/NousResearch/hermes-agent/pull/6809))
- Handle assistant thread lifecycle events ([#6433](https://github.com/NousResearch/hermes-agent/pull/6433))
### Matrix
- **Migrated from matrix-nio to mautrix-python** ([#7518](https://github.com/NousResearch/hermes-agent/pull/7518))
- SQLite crypto store replacing pickle (fixes E2EE decryption) — @alt-glitch ([#7981](https://github.com/NousResearch/hermes-agent/pull/7981))
- Cross-signing recovery key verification for E2EE migration ([#8282](https://github.com/NousResearch/hermes-agent/pull/8282))
- DM mention threads + group chat events for Feishu ([#7423](https://github.com/NousResearch/hermes-agent/pull/7423))
### Gateway Core
- **Unified proxy support** — SOCKS, DISCORD_PROXY, multi-platform with macOS auto-detection ([#6814](https://github.com/NousResearch/hermes-agent/pull/6814))
- **Inbound text batching** for Discord, Matrix, WeCom + adaptive delay ([#6979](https://github.com/NousResearch/hermes-agent/pull/6979))
- **Surface natural mid-turn assistant messages** in chat platforms ([#7978](https://github.com/NousResearch/hermes-agent/pull/7978))
- **WSL-aware gateway** with smart systemd detection ([#7510](https://github.com/NousResearch/hermes-agent/pull/7510))
- **All missing platforms added to setup wizard** ([#7949](https://github.com/NousResearch/hermes-agent/pull/7949))
- **Per-platform `tool_progress` overrides** ([#6348](https://github.com/NousResearch/hermes-agent/pull/6348))
- **Configurable 'still working' notification interval** ([#8572](https://github.com/NousResearch/hermes-agent/pull/8572))
- `/model` switch persists across messages ([#7081](https://github.com/NousResearch/hermes-agent/pull/7081))
- `/usage` shows rate limits, cost, and token details between turns ([#7038](https://github.com/NousResearch/hermes-agent/pull/7038))
- Drain in-flight work before restart ([#7503](https://github.com/NousResearch/hermes-agent/pull/7503))
- Don't evict cached agent on failed runs — prevents MCP restart loop ([#7539](https://github.com/NousResearch/hermes-agent/pull/7539))
- Replace `os.environ` session state with `contextvars` ([#7454](https://github.com/NousResearch/hermes-agent/pull/7454))
- Derive channel directory platforms from enum instead of hardcoded list ([#7450](https://github.com/NousResearch/hermes-agent/pull/7450))
- Validate image downloads before caching (cross-platform) ([#7125](https://github.com/NousResearch/hermes-agent/pull/7125))
- Cross-platform webhook delivery for all platforms ([#7095](https://github.com/NousResearch/hermes-agent/pull/7095))
- Cron Discord thread_id delivery support ([#7106](https://github.com/NousResearch/hermes-agent/pull/7106))
- Feishu QR-based bot onboarding ([#8570](https://github.com/NousResearch/hermes-agent/pull/8570))
- Gateway status scoped to active profile ([#7951](https://github.com/NousResearch/hermes-agent/pull/7951))
- Prevent background process notifications from triggering false pairing requests ([#6434](https://github.com/NousResearch/hermes-agent/pull/6434))
---
## 🖥️ CLI & User Experience
### Interactive CLI
- **Termux / Android support** — adapted install paths, TUI, voice, `/image` ([#6834](https://github.com/NousResearch/hermes-agent/pull/6834))
- **Native `/model` picker modal** for provider → model selection ([#8003](https://github.com/NousResearch/hermes-agent/pull/8003))
- **Live per-tool elapsed timer** restored in TUI spinner ([#7359](https://github.com/NousResearch/hermes-agent/pull/7359))
- **Stacked tool progress scrollback** in TUI ([#8201](https://github.com/NousResearch/hermes-agent/pull/8201))
- **Random tips on new session start** (CLI + gateway, 279 tips) ([#8225](https://github.com/NousResearch/hermes-agent/pull/8225), [#8237](https://github.com/NousResearch/hermes-agent/pull/8237))
- **`hermes dump`** — copy-pasteable setup summary for debugging ([#6550](https://github.com/NousResearch/hermes-agent/pull/6550))
- **`hermes backup` / `hermes import`** — full config backup and restore ([#7997](https://github.com/NousResearch/hermes-agent/pull/7997))
- **WSL environment hint** in system prompt ([#8285](https://github.com/NousResearch/hermes-agent/pull/8285))
- **Profile creation UX** — seed SOUL.md + credential warning ([#8553](https://github.com/NousResearch/hermes-agent/pull/8553))
- Shell-aware sudo detection, empty password support ([#6517](https://github.com/NousResearch/hermes-agent/pull/6517))
- Flush stdin after curses/terminal menus to prevent escape sequence leakage ([#7167](https://github.com/NousResearch/hermes-agent/pull/7167))
- Handle broken stdin in prompt_toolkit startup ([#8560](https://github.com/NousResearch/hermes-agent/pull/8560))
### Setup & Configuration
- **Per-platform display verbosity** configuration ([#8006](https://github.com/NousResearch/hermes-agent/pull/8006))
- **Component-separated logging** with session context and filtering ([#7991](https://github.com/NousResearch/hermes-agent/pull/7991))
- **`network.force_ipv4`** config to fix IPv6 timeout issues ([#8196](https://github.com/NousResearch/hermes-agent/pull/8196))
- **Standardize message whitespace and JSON formatting** ([#7988](https://github.com/NousResearch/hermes-agent/pull/7988))
- **Rebrand OpenClaw → Hermes** during migration ([#8210](https://github.com/NousResearch/hermes-agent/pull/8210))
- Config.yaml takes priority over env vars for auxiliary settings ([#7889](https://github.com/NousResearch/hermes-agent/pull/7889))
- Harden setup provider flows + live OpenRouter catalog refresh ([#7078](https://github.com/NousResearch/hermes-agent/pull/7078))
- Normalize reasoning effort ordering across all surfaces ([#6804](https://github.com/NousResearch/hermes-agent/pull/6804))
- Remove dead `LLM_MODEL` env var + migration to clear stale entries ([#6543](https://github.com/NousResearch/hermes-agent/pull/6543))
- Remove `/prompt` slash command — prefix expansion footgun ([#6752](https://github.com/NousResearch/hermes-agent/pull/6752))
- `HERMES_HOME_MODE` env var to override permissions — @ygd58 ([#6993](https://github.com/NousResearch/hermes-agent/pull/6993))
- Fall back to default model when model config is empty ([#8303](https://github.com/NousResearch/hermes-agent/pull/8303))
- Warn when compression model context is too small ([#7894](https://github.com/NousResearch/hermes-agent/pull/7894))
---
## 🔧 Tool System
### Environments & Execution
- **Unified spawn-per-call execution layer** for environments ([#6343](https://github.com/NousResearch/hermes-agent/pull/6343))
- **Unified file sync** with mtime tracking, deletion, and transactional state ([#7087](https://github.com/NousResearch/hermes-agent/pull/7087))
- **Persistent sandbox envs** survive between turns ([#6412](https://github.com/NousResearch/hermes-agent/pull/6412))
- **Bulk file sync** via tar pipe for SSH/Modal backends — @alt-glitch ([#8014](https://github.com/NousResearch/hermes-agent/pull/8014))
- **Daytona** — bulk upload, config bridge, silent disk cap ([#7538](https://github.com/NousResearch/hermes-agent/pull/7538))
- Foreground timeout cap to prevent session deadlocks ([#7082](https://github.com/NousResearch/hermes-agent/pull/7082))
- Guard invalid command values ([#6417](https://github.com/NousResearch/hermes-agent/pull/6417))
### MCP
- **`hermes mcp add --env` and `--preset`** support ([#7970](https://github.com/NousResearch/hermes-agent/pull/7970))
- Combine `content` and `structuredContent` when both present ([#7118](https://github.com/NousResearch/hermes-agent/pull/7118))
- MCP tool name deconfliction fixes ([#7654](https://github.com/NousResearch/hermes-agent/pull/7654))
### Browser
- Browser hardening — dead code removal, caching, scroll perf, security, thread safety ([#7354](https://github.com/NousResearch/hermes-agent/pull/7354))
- `/browser connect` auto-launch uses dedicated Chrome profile dir ([#6821](https://github.com/NousResearch/hermes-agent/pull/6821))
- Reap orphaned browser sessions on startup ([#7931](https://github.com/NousResearch/hermes-agent/pull/7931))
### Voice & Vision
- **Voxtral TTS provider** (Mistral AI) ([#7653](https://github.com/NousResearch/hermes-agent/pull/7653))
- **TTS speed support** for Edge TTS, OpenAI TTS, MiniMax ([#8666](https://github.com/NousResearch/hermes-agent/pull/8666))
- **Vision auto-resize** for oversized images, raise limit to 20 MB, retry-on-failure ([#7883](https://github.com/NousResearch/hermes-agent/pull/7883), [#7902](https://github.com/NousResearch/hermes-agent/pull/7902))
- STT provider-model mismatch fix (whisper-1 vs faster-whisper) ([#7113](https://github.com/NousResearch/hermes-agent/pull/7113))
### Other Tools
- **`hermes dump`** command for setup summary ([#6550](https://github.com/NousResearch/hermes-agent/pull/6550))
- TODO store enforces ID uniqueness during replace operations ([#7986](https://github.com/NousResearch/hermes-agent/pull/7986))
- List all available toolsets in `delegate_task` schema description ([#8231](https://github.com/NousResearch/hermes-agent/pull/8231))
- API server: tool progress as custom SSE event to prevent model corruption ([#7500](https://github.com/NousResearch/hermes-agent/pull/7500))
- API server: share one Docker container across all conversations ([#7127](https://github.com/NousResearch/hermes-agent/pull/7127))
---
## 🧩 Skills Ecosystem
- **Centralized skills index + tree cache** — eliminates rate-limit failures on install ([#8575](https://github.com/NousResearch/hermes-agent/pull/8575))
- **More aggressive skill loading instructions** in system prompt (v3) ([#8209](https://github.com/NousResearch/hermes-agent/pull/8209), [#8286](https://github.com/NousResearch/hermes-agent/pull/8286))
- **Google Workspace skill** migrated to GWS CLI backend ([#6788](https://github.com/NousResearch/hermes-agent/pull/6788))
- **Creative divergence strategies** skill — @SHL0MS ([#6882](https://github.com/NousResearch/hermes-agent/pull/6882))
- **Creative ideation** — constraint-driven project generation — @SHL0MS ([#7555](https://github.com/NousResearch/hermes-agent/pull/7555))
- Parallelize skills browse/search to prevent hanging ([#7301](https://github.com/NousResearch/hermes-agent/pull/7301))
- Read name from SKILL.md frontmatter in skills_sync ([#7623](https://github.com/NousResearch/hermes-agent/pull/7623))
---
## 🔒 Security & Reliability
### Security Hardening
- **Twilio webhook signature validation** — SMS RCE fix ([#7933](https://github.com/NousResearch/hermes-agent/pull/7933))
- **Shell injection neutralization** in `_write_to_sandbox` via path quoting ([#7940](https://github.com/NousResearch/hermes-agent/pull/7940))
- **Git argument injection** and path traversal prevention in checkpoint manager ([#7944](https://github.com/NousResearch/hermes-agent/pull/7944))
- **SSRF redirect bypass** in Slack image uploads + base.py cache helpers ([#7151](https://github.com/NousResearch/hermes-agent/pull/7151))
- **Path traversal, credential gate, DANGEROUS_PATTERNS gaps** ([#7156](https://github.com/NousResearch/hermes-agent/pull/7156))
- **API bind guard** — enforce `API_SERVER_KEY` for non-loopback binding ([#7455](https://github.com/NousResearch/hermes-agent/pull/7455))
- **Approval button authorization** — require auth for session continuation — @Cafexss ([#6930](https://github.com/NousResearch/hermes-agent/pull/6930))
- Path boundary enforcement in skill manager operations ([#7156](https://github.com/NousResearch/hermes-agent/pull/7156))
- DingTalk/API webhook URL origin validation, header injection rejection ([#7455](https://github.com/NousResearch/hermes-agent/pull/7455))
### Reliability
- **Contextual error diagnostics** for invalid API responses ([#8565](https://github.com/NousResearch/hermes-agent/pull/8565))
- **Prevent 400 format errors** from triggering compression loop on Codex ([#6751](https://github.com/NousResearch/hermes-agent/pull/6751))
- **Don't halve context_length** on output-cap-too-large errors — @KUSH42 ([#6664](https://github.com/NousResearch/hermes-agent/pull/6664))
- **Recover primary client** on OpenAI transport errors ([#7108](https://github.com/NousResearch/hermes-agent/pull/7108))
- **Credential pool rotation** on billing-classified 400s ([#7112](https://github.com/NousResearch/hermes-agent/pull/7112))
- **Auto-increase stream read timeout** for local LLM providers ([#6967](https://github.com/NousResearch/hermes-agent/pull/6967))
- **Fall back to default certs** when CA bundle path doesn't exist ([#7352](https://github.com/NousResearch/hermes-agent/pull/7352))
- **Disambiguate usage-limit patterns** in error classifier — @sprmn24 ([#6836](https://github.com/NousResearch/hermes-agent/pull/6836))
- Harden cron script timeout and provider recovery ([#7079](https://github.com/NousResearch/hermes-agent/pull/7079))
- Gateway interrupt detection resilient to monitor task failures ([#8208](https://github.com/NousResearch/hermes-agent/pull/8208))
- Prevent unwanted session auto-reset after graceful gateway restarts ([#8299](https://github.com/NousResearch/hermes-agent/pull/8299))
- Prevent duplicate update prompt spam in gateway watcher ([#8343](https://github.com/NousResearch/hermes-agent/pull/8343))
- Deduplicate reasoning items in Responses API input ([#7946](https://github.com/NousResearch/hermes-agent/pull/7946))
### Infrastructure
- **Multi-arch Docker image** — amd64 + arm64 ([#6124](https://github.com/NousResearch/hermes-agent/pull/6124))
- **Docker runs as non-root user** with virtualenv — @benbarclay contributing ([#8226](https://github.com/NousResearch/hermes-agent/pull/8226))
- **Use `uv`** for Docker dependency resolution to fix resolution-too-deep ([#6965](https://github.com/NousResearch/hermes-agent/pull/6965))
- **Container-aware Nix CLI** — auto-route into managed container — @alt-glitch ([#7543](https://github.com/NousResearch/hermes-agent/pull/7543))
- **Nix shared-state permission model** for interactive CLI users — @alt-glitch ([#6796](https://github.com/NousResearch/hermes-agent/pull/6796))
- **Per-profile subprocess HOME isolation** ([#7357](https://github.com/NousResearch/hermes-agent/pull/7357))
- Profile paths fixed in Docker — profiles go to mounted volume ([#7170](https://github.com/NousResearch/hermes-agent/pull/7170))
- Docker container gateway pathway hardened ([#8614](https://github.com/NousResearch/hermes-agent/pull/8614))
- Enable unbuffered stdout for live Docker logs ([#6749](https://github.com/NousResearch/hermes-agent/pull/6749))
- Install procps in Docker image — @HiddenPuppy ([#7032](https://github.com/NousResearch/hermes-agent/pull/7032))
- Shallow git clone for faster installation — @sosyz ([#8396](https://github.com/NousResearch/hermes-agent/pull/8396))
- `hermes update` always reset on stash conflict ([#7010](https://github.com/NousResearch/hermes-agent/pull/7010))
- Write update exit code before gateway restart (cgroup kill race) ([#8288](https://github.com/NousResearch/hermes-agent/pull/8288))
- Nix: `setupSecrets` optional, tirith runtime dep — @devorun, @ethernet8023 ([#6261](https://github.com/NousResearch/hermes-agent/pull/6261), [#6721](https://github.com/NousResearch/hermes-agent/pull/6721))
- launchd stop uses `bootout` so `KeepAlive` doesn't respawn ([#7119](https://github.com/NousResearch/hermes-agent/pull/7119))
---
## 🐛 Notable Bug Fixes
- Fix: `/model` switch not persisting across gateway messages ([#7081](https://github.com/NousResearch/hermes-agent/pull/7081))
- Fix: session-scoped gateway model overrides ignored — @Hygaard ([#7662](https://github.com/NousResearch/hermes-agent/pull/7662))
- Fix: compaction model context length ignoring config — 3 related issues ([#8258](https://github.com/NousResearch/hermes-agent/pull/8258), [#8107](https://github.com/NousResearch/hermes-agent/pull/8107))
- Fix: OpenCode.ai context window resolved to 128K instead of 1M ([#6472](https://github.com/NousResearch/hermes-agent/pull/6472))
- Fix: Codex fallback auth-store lookup — @cherifya ([#6462](https://github.com/NousResearch/hermes-agent/pull/6462))
- Fix: duplicate completion notifications when process killed ([#7124](https://github.com/NousResearch/hermes-agent/pull/7124))
- Fix: agent daemon thread prevents orphan CLI processes on tab close ([#8557](https://github.com/NousResearch/hermes-agent/pull/8557))
- Fix: stale image attachment on text paste and voice input ([#7077](https://github.com/NousResearch/hermes-agent/pull/7077))
- Fix: DM thread session seeding causing cross-thread contamination ([#7084](https://github.com/NousResearch/hermes-agent/pull/7084))
- Fix: OpenClaw migration shows dry-run preview before executing ([#6769](https://github.com/NousResearch/hermes-agent/pull/6769))
- Fix: auth errors misclassified as retryable — @kuishou68 ([#7027](https://github.com/NousResearch/hermes-agent/pull/7027))
- Fix: Copilot-Integration-Id header missing ([#7083](https://github.com/NousResearch/hermes-agent/pull/7083))
- Fix: ACP session capabilities — @luyao618 ([#6985](https://github.com/NousResearch/hermes-agent/pull/6985))
- Fix: ACP PromptResponse usage from top-level fields ([#7086](https://github.com/NousResearch/hermes-agent/pull/7086))
- Fix: several failing/flaky tests on main — @dsocolobsky ([#6777](https://github.com/NousResearch/hermes-agent/pull/6777))
- Fix: backup marker filenames — @sprmn24 ([#8600](https://github.com/NousResearch/hermes-agent/pull/8600))
- Fix: `NoneType` in fast_mode check — @0xbyt4 ([#7350](https://github.com/NousResearch/hermes-agent/pull/7350))
- Fix: missing imports in uninstall.py — @JiayuuWang ([#7034](https://github.com/NousResearch/hermes-agent/pull/7034))
---
## 📚 Documentation
- Platform adapter developer guide + WeCom Callback docs ([#7969](https://github.com/NousResearch/hermes-agent/pull/7969))
- Cron troubleshooting guide ([#7122](https://github.com/NousResearch/hermes-agent/pull/7122))
- Streaming timeout auto-detection for local LLMs ([#6990](https://github.com/NousResearch/hermes-agent/pull/6990))
- Tool-use enforcement documentation expanded ([#7984](https://github.com/NousResearch/hermes-agent/pull/7984))
- BlueBubbles pairing instructions ([#6548](https://github.com/NousResearch/hermes-agent/pull/6548))
- Telegram proxy support section ([#6348](https://github.com/NousResearch/hermes-agent/pull/6348))
- `hermes dump` and `hermes logs` CLI reference ([#6552](https://github.com/NousResearch/hermes-agent/pull/6552))
- `tool_progress_overrides` configuration reference ([#6364](https://github.com/NousResearch/hermes-agent/pull/6364))
- Compression model context length warning docs ([#7879](https://github.com/NousResearch/hermes-agent/pull/7879))
---
## 👥 Contributors
**269 merged PRs** from **24 contributors** across **487 commits**.
### Community Contributors
- **@alt-glitch** (6 PRs) — Nix container-aware CLI, shared-state permissions, Matrix SQLite crypto store, bulk SSH/Modal file sync, Matrix mautrix compat
- **@SHL0MS** (2 PRs) — Creative divergence strategies skill, creative ideation skill
- **@sprmn24** (2 PRs) — Error classifier disambiguation, backup marker fix
- **@nicoloboschi** — Hindsight memory plugin feature parity
- **@Hygaard** — Session-scoped gateway model override fix
- **@jarvis-phw** — Discord allowed_channels whitelist
- **@Kathie-yu** — Honcho initOnSessionStart for tools mode
- **@hermes-agent-dhabibi** — Discord forum channel topic inheritance
- **@kira-ariaki** — Discord .log attachments and size limit
- **@cherifya** — Codex fallback auth-store lookup
- **@Cafexss** — Security: auth for session continuation
- **@KUSH42** — Compaction context_length fix
- **@kuishou68** — Auth error retryable classification fix
- **@luyao618** — ACP session capabilities
- **@ygd58** — HERMES_HOME_MODE env var override
- **@0xbyt4** — Fast mode NoneType fix
- **@JiayuuWang** — CLI uninstall import fix
- **@HiddenPuppy** — Docker procps installation
- **@dsocolobsky** — Test suite fixes
- **@bobashopcashier** (1 PR) — Graceful gateway drain before restart (salvaged into #7503 from #7290)
- **@benbarclay** — Docker image tag simplification
- **@sosyz** — Shallow git clone for faster install
- **@devorun** — Nix setupSecrets optional
- **@ethernet8023** — Nix tirith runtime dep
---
**Full Changelog**: [v2026.4.8...v2026.4.13](https://github.com/NousResearch/hermes-agent/compare/v2026.4.8...v2026.4.13)

View File

@@ -1,566 +0,0 @@
# SECURE CODING GUIDELINES
## Hermes Agent Development Security Standards
**Version:** 1.0
**Effective Date:** March 30, 2026
---
## 1. GENERAL PRINCIPLES
### 1.1 Security-First Mindset
- Every feature must be designed with security in mind
- Assume all input is malicious until proven otherwise
- Defense in depth: multiple layers of security controls
- Fail securely: when security controls fail, default to denial
### 1.2 Threat Model
Primary threats to consider:
- Malicious user prompts
- Compromised or malicious skills
- Supply chain attacks
- Insider threats
- Accidental data exposure
---
## 2. INPUT VALIDATION
### 2.1 Validate All Input
```python
# ❌ INCORRECT
def process_file(path: str):
with open(path) as f:
return f.read()
# ✅ CORRECT
from pydantic import BaseModel, validator
import re
class FileRequest(BaseModel):
path: str
max_size: int = 1000000
@validator('path')
def validate_path(cls, v):
# Block path traversal
if '..' in v or v.startswith('/'):
raise ValueError('Invalid path characters')
# Allowlist safe characters
if not re.match(r'^[\w\-./]+$', v):
raise ValueError('Invalid characters in path')
return v
@validator('max_size')
def validate_size(cls, v):
if v < 0 or v > 10000000:
raise ValueError('Size out of range')
return v
def process_file(request: FileRequest):
# Now safe to use request.path
pass
```
### 2.2 Length Limits
Always enforce maximum lengths:
```python
MAX_INPUT_LENGTH = 10000
MAX_FILENAME_LENGTH = 255
MAX_PATH_LENGTH = 4096
def validate_length(value: str, max_len: int, field_name: str):
if len(value) > max_len:
raise ValueError(f"{field_name} exceeds maximum length of {max_len}")
```
### 2.3 Type Safety
Use type hints and enforce them:
```python
from typing import Union
def safe_function(user_id: int, message: str) -> dict:
if not isinstance(user_id, int):
raise TypeError("user_id must be an integer")
if not isinstance(message, str):
raise TypeError("message must be a string")
# ... function logic
```
---
## 3. COMMAND EXECUTION
### 3.1 Never Use shell=True
```python
import subprocess
import shlex
# ❌ NEVER DO THIS
subprocess.run(f"ls {user_input}", shell=True)
# ❌ NEVER DO THIS EITHER
cmd = f"cat {filename}"
os.system(cmd)
# ✅ CORRECT - Use list arguments
subprocess.run(["ls", user_input], shell=False)
# ✅ CORRECT - Use shlex for complex cases
cmd_parts = shlex.split(user_input)
subprocess.run(["ls"] + cmd_parts, shell=False)
```
### 3.2 Command Allowlisting
```python
ALLOWED_COMMANDS = frozenset([
"ls", "cat", "grep", "find", "git", "python", "pip"
])
def validate_command(command: str):
parts = shlex.split(command)
if parts[0] not in ALLOWED_COMMANDS:
raise SecurityError(f"Command '{parts[0]}' not allowed")
```
### 3.3 Input Sanitization
```python
import re
def sanitize_shell_input(value: str) -> str:
"""Remove dangerous shell metacharacters."""
# Block shell metacharacters
dangerous = re.compile(r'[;&|`$(){}[\]\\]')
if dangerous.search(value):
raise ValueError("Shell metacharacters not allowed")
return value
```
---
## 4. FILE OPERATIONS
### 4.1 Path Validation
```python
from pathlib import Path
class FileSandbox:
def __init__(self, root: Path):
self.root = root.resolve()
def validate_path(self, user_path: str) -> Path:
"""Validate and resolve user-provided path within sandbox."""
# Expand user home
expanded = Path(user_path).expanduser()
# Resolve to absolute path
try:
resolved = expanded.resolve()
except (OSError, ValueError) as e:
raise SecurityError(f"Invalid path: {e}")
# Ensure path is within sandbox
try:
resolved.relative_to(self.root)
except ValueError:
raise SecurityError("Path outside sandbox")
return resolved
def safe_open(self, user_path: str, mode: str = 'r'):
safe_path = self.validate_path(user_path)
return open(safe_path, mode)
```
### 4.2 Prevent Symlink Attacks
```python
import os
def safe_read_file(filepath: Path):
"""Read file, following symlinks only within allowed directories."""
# Resolve symlinks
real_path = filepath.resolve()
# Verify still in allowed location after resolution
if not str(real_path).startswith(str(SAFE_ROOT)):
raise SecurityError("Symlink escape detected")
# Verify it's a regular file
if not real_path.is_file():
raise SecurityError("Not a regular file")
return real_path.read_text()
```
### 4.3 Temporary Files
```python
import tempfile
import os
def create_secure_temp_file():
"""Create temp file with restricted permissions."""
# Create with restrictive permissions
fd, path = tempfile.mkstemp(prefix="hermes_", suffix=".tmp")
try:
# Set owner-read/write only
os.chmod(path, 0o600)
return fd, path
except:
os.close(fd)
os.unlink(path)
raise
```
---
## 5. SECRET MANAGEMENT
### 5.1 Environment Variables
```python
import os
# ❌ NEVER DO THIS
def execute_command(command: str):
# Child inherits ALL environment
subprocess.run(command, shell=True, env=os.environ)
# ✅ CORRECT - Explicit whitelisting
_ALLOWED_ENV = frozenset([
"PATH", "HOME", "USER", "LANG", "TERM", "SHELL"
])
def get_safe_environment():
return {k: v for k, v in os.environ.items()
if k in _ALLOWED_ENV}
def execute_command(command: str):
subprocess.run(
command,
shell=False,
env=get_safe_environment()
)
```
### 5.2 Secret Detection
```python
import re
_SECRET_PATTERNS = [
re.compile(r'sk-[a-zA-Z0-9]{20,}'), # OpenAI-style keys
re.compile(r'ghp_[a-zA-Z0-9]{36}'), # GitHub PAT
re.compile(r'[a-zA-Z0-9]{40}'), # Generic high-entropy strings
]
def detect_secrets(text: str) -> list:
"""Detect potential secrets in text."""
findings = []
for pattern in _SECRET_PATTERNS:
matches = pattern.findall(text)
findings.extend(matches)
return findings
def redact_secrets(text: str) -> str:
"""Redact detected secrets."""
for pattern in _SECRET_PATTERNS:
text = pattern.sub('***REDACTED***', text)
return text
```
### 5.3 Secure Logging
```python
import logging
from agent.redact import redact_sensitive_text
class SecureLogger:
def __init__(self, logger: logging.Logger):
self.logger = logger
def debug(self, msg: str, *args, **kwargs):
self.logger.debug(redact_sensitive_text(msg), *args, **kwargs)
def info(self, msg: str, *args, **kwargs):
self.logger.info(redact_sensitive_text(msg), *args, **kwargs)
def warning(self, msg: str, *args, **kwargs):
self.logger.warning(redact_sensitive_text(msg), *args, **kwargs)
def error(self, msg: str, *args, **kwargs):
self.logger.error(redact_sensitive_text(msg), *args, **kwargs)
```
---
## 6. NETWORK SECURITY
### 6.1 URL Validation
```python
from urllib.parse import urlparse
import ipaddress
_BLOCKED_SCHEMES = frozenset(['file', 'ftp', 'gopher'])
_BLOCKED_HOSTS = frozenset([
'localhost', '127.0.0.1', '0.0.0.0',
'169.254.169.254', # AWS metadata
'[::1]', '[::]'
])
_PRIVATE_NETWORKS = [
ipaddress.ip_network('10.0.0.0/8'),
ipaddress.ip_network('172.16.0.0/12'),
ipaddress.ip_network('192.168.0.0/16'),
ipaddress.ip_network('127.0.0.0/8'),
ipaddress.ip_network('169.254.0.0/16'), # Link-local
]
def validate_url(url: str) -> bool:
"""Validate URL is safe to fetch."""
parsed = urlparse(url)
# Check scheme
if parsed.scheme not in ('http', 'https'):
raise ValueError(f"Scheme '{parsed.scheme}' not allowed")
# Check hostname
hostname = parsed.hostname
if not hostname:
raise ValueError("No hostname in URL")
if hostname.lower() in _BLOCKED_HOSTS:
raise ValueError("Host not allowed")
# Check IP addresses
try:
ip = ipaddress.ip_address(hostname)
for network in _PRIVATE_NETWORKS:
if ip in network:
raise ValueError("Private IP address not allowed")
except ValueError:
pass # Not an IP, continue
return True
```
### 6.2 Redirect Handling
```python
import requests
def safe_get(url: str, max_redirects: int = 5):
"""GET URL with redirect validation."""
session = requests.Session()
session.max_redirects = max_redirects
# Validate initial URL
validate_url(url)
# Custom redirect handler
response = session.get(
url,
allow_redirects=True,
hooks={'response': lambda r, *args, **kwargs: validate_url(r.url)}
)
return response
```
---
## 7. AUTHENTICATION & AUTHORIZATION
### 7.1 API Key Validation
```python
import secrets
import hmac
import hashlib
def constant_time_compare(val1: str, val2: str) -> bool:
"""Compare strings in constant time to prevent timing attacks."""
return hmac.compare_digest(val1.encode(), val2.encode())
def validate_api_key(provided_key: str, expected_key: str) -> bool:
"""Validate API key using constant-time comparison."""
if not provided_key or not expected_key:
return False
return constant_time_compare(provided_key, expected_key)
```
### 7.2 Session Management
```python
import secrets
from datetime import datetime, timedelta
class SessionManager:
SESSION_TIMEOUT = timedelta(hours=24)
def create_session(self, user_id: str) -> str:
"""Create secure session token."""
token = secrets.token_urlsafe(32)
expires = datetime.utcnow() + self.SESSION_TIMEOUT
# Store in database with expiration
return token
def validate_session(self, token: str) -> bool:
"""Validate session token."""
# Lookup in database
# Check expiration
# Validate token format
return True
```
---
## 8. ERROR HANDLING
### 8.1 Secure Error Messages
```python
import logging
# Internal detailed logging
logger = logging.getLogger(__name__)
class UserFacingError(Exception):
"""Error safe to show to users."""
pass
def process_request(data: dict):
try:
result = internal_operation(data)
return result
except ValueError as e:
# Log full details internally
logger.error(f"Validation error: {e}", exc_info=True)
# Return safe message to user
raise UserFacingError("Invalid input provided")
except Exception as e:
# Log full details internally
logger.error(f"Unexpected error: {e}", exc_info=True)
# Generic message to user
raise UserFacingError("An error occurred")
```
### 8.2 Exception Handling
```python
def safe_operation():
try:
risky_operation()
except Exception as e:
# Always clean up resources
cleanup_resources()
# Log securely
logger.error(f"Operation failed: {redact_sensitive_text(str(e))}")
# Re-raise or convert
raise
```
---
## 9. CRYPTOGRAPHY
### 9.1 Password Hashing
```python
import bcrypt
def hash_password(password: str) -> str:
"""Hash password using bcrypt."""
salt = bcrypt.gensalt(rounds=12)
hashed = bcrypt.hashpw(password.encode(), salt)
return hashed.decode()
def verify_password(password: str, hashed: str) -> bool:
"""Verify password against hash."""
return bcrypt.checkpw(password.encode(), hashed.encode())
```
### 9.2 Secure Random
```python
import secrets
def generate_token(length: int = 32) -> str:
"""Generate cryptographically secure token."""
return secrets.token_urlsafe(length)
def generate_pin(length: int = 6) -> str:
"""Generate secure numeric PIN."""
return ''.join(str(secrets.randbelow(10)) for _ in range(length))
```
---
## 10. CODE REVIEW CHECKLIST
### Before Submitting Code:
- [ ] All user inputs validated
- [ ] No shell=True in subprocess calls
- [ ] All file paths validated and sandboxed
- [ ] Secrets not logged or exposed
- [ ] URLs validated before fetching
- [ ] Error messages don't leak sensitive info
- [ ] No hardcoded credentials
- [ ] Proper exception handling
- [ ] Security tests included
- [ ] Documentation updated
### Security-Focused Review Questions:
1. What happens if this receives malicious input?
2. Can this leak sensitive data?
3. Are there privilege escalation paths?
4. What if the external service is compromised?
5. Is the error handling secure?
---
## 11. TESTING SECURITY
### 11.1 Security Unit Tests
```python
def test_path_traversal_blocked():
sandbox = FileSandbox(Path("/safe/path"))
with pytest.raises(SecurityError):
sandbox.validate_path("../../../etc/passwd")
def test_command_injection_blocked():
with pytest.raises(SecurityError):
validate_command("ls; rm -rf /")
def test_secret_redaction():
text = "Key: sk-test123456789"
redacted = redact_secrets(text)
assert "sk-test" not in redacted
```
### 11.2 Fuzzing
```python
import hypothesis.strategies as st
from hypothesis import given
@given(st.text())
def test_input_validation(input_text):
# Should never crash, always validate or reject
try:
result = process_input(input_text)
assert isinstance(result, ExpectedType)
except ValidationError:
pass # Expected for invalid input
```
---
## 12. INCIDENT RESPONSE
### Security Incident Procedure:
1. **Stop** - Halt the affected system/process
2. **Assess** - Determine scope and impact
3. **Contain** - Prevent further damage
4. **Investigate** - Gather evidence
5. **Remediate** - Fix the vulnerability
6. **Recover** - Restore normal operations
7. **Learn** - Document and improve
### Emergency Contacts:
- Security Team: security@example.com
- On-call: +1-XXX-XXX-XXXX
- Slack: #security-incidents
---
**Document Owner:** Security Team
**Review Cycle:** Quarterly
**Last Updated:** March 30, 2026

View File

@@ -1,705 +0,0 @@
# HERMES AGENT - COMPREHENSIVE SECURITY AUDIT REPORT
**Audit Date:** March 30, 2026
**Auditor:** Security Analysis Agent
**Scope:** Entire codebase including authentication, command execution, file operations, sandbox environments, and API endpoints
---
## EXECUTIVE SUMMARY
The Hermes Agent codebase contains **32 identified security issues** across critical severity (5), high severity (12), medium severity (10), and low severity (5). The most critical vulnerabilities involve command injection vectors, sandbox escape possibilities, and secret leakage risks.
**Overall Security Posture: MODERATE-HIGH RISK**
- Well-designed approval system for dangerous commands
- Good secret redaction mechanisms
- Insufficient input validation in several areas
- Multiple command injection vectors
- Incomplete sandbox isolation in some environments
---
## 1. CVSS-SCORED VULNERABILITY REPORT
### CRITICAL SEVERITY (CVSS 9.0-10.0)
#### V-001: Command Injection via shell=True in Subprocess Calls
- **CVSS Score:** 9.8 (Critical)
- **Location:** `tools/terminal_tool.py`, `tools/file_operations.py`, `tools/environments/*.py`
- **Description:** Multiple subprocess calls use shell=True with user-controlled input, enabling arbitrary command execution
- **Attack Vector:** Local/Remote via agent prompts or malicious skills
- **Evidence:**
```python
# terminal_tool.py line ~460
subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, ...)
# Command strings constructed from user input without proper sanitization
```
- **Impact:** Complete system compromise, data exfiltration, malware installation
- **Remediation:** Use subprocess without shell=True, pass arguments as lists, implement strict input validation
#### V-002: Path Traversal in File Operations
- **CVSS Score:** 9.1 (Critical)
- **Location:** `tools/file_operations.py`, `tools/file_tools.py`
- **Description:** Insufficient path validation allows access to sensitive system files
- **Attack Vector:** Malicious file paths like `../../../etc/shadow` or `~/.ssh/id_rsa`
- **Evidence:**
```python
# file_operations.py - _expand_path() allows ~username expansion
# which can be exploited with crafted usernames
```
- **Impact:** Unauthorized file read/write, credential theft, system compromise
- **Remediation:** Implement strict path canonicalization and sandbox boundaries
#### V-003: Secret Leakage via Environment Variables in Sandboxes
- **CVSS Score:** 9.3 (Critical)
- **Location:** `tools/code_execution_tool.py`, `tools/environments/*.py`
- **Description:** Child processes inherit environment variables containing secrets
- **Attack Vector:** Malicious code executed via execute_code or terminal
- **Evidence:**
```python
# code_execution_tool.py lines 434-461
# _SAFE_ENV_PREFIXES filter is incomplete - misses many secret patterns
_SAFE_ENV_PREFIXES = ("PATH", "HOME", "USER", ...)
_SECRET_SUBSTRINGS = ("TOKEN", "SECRET", "PASSWORD", ...)
# Only blocks explicit patterns - many secret env vars slip through
```
- **Impact:** API key theft, credential exfiltration, unauthorized access to external services
- **Remediation:** Whitelist-only approach for env vars, explicit secret scanning
#### V-004: Sudo Password Exposure via Command Line
- **CVSS Score:** 9.0 (Critical)
- **Location:** `tools/terminal_tool.py`, `_transform_sudo_command()`
- **Description:** Sudo passwords may be exposed in process lists via command line arguments
- **Attack Vector:** Local attackers reading /proc or ps output
- **Evidence:**
```python
# Line 275: sudo_stdin passed via printf pipe
exec_command = f"printf '%s\\n' {shlex.quote(sudo_stdin.rstrip())} | {exec_command}"
```
- **Impact:** Privilege escalation credential theft
- **Remediation:** Use file descriptor passing, avoid shell command construction with secrets
#### V-005: SSRF via Unsafe URL Handling
- **CVSS Score:** 9.4 (Critical)
- **Location:** `tools/web_tools.py`, `tools/browser_tool.py`
- **Description:** URL safety checks can be bypassed via DNS rebinding and redirect chains
- **Attack Vector:** Malicious URLs targeting internal services (169.254.169.254, localhost)
- **Evidence:**
```python
# url_safety.py - is_safe_url() vulnerable to TOCTOU
# DNS resolution and actual connection are separate operations
```
- **Impact:** Internal service access, cloud metadata theft, port scanning
- **Remediation:** Implement connection-level validation, use egress proxy
---
### HIGH SEVERITY (CVSS 7.0-8.9)
#### V-006: Insecure Deserialization in MCP OAuth
- **CVSS Score:** 8.8 (High)
- **Location:** `tools/mcp_oauth.py`, token storage
- **Description:** JSON token data loaded without schema validation
- **Attack Vector:** Malicious token files crafted by local attackers
- **Remediation:** Add JSON schema validation, sign stored tokens
#### V-007: SQL Injection in ResponseStore
- **CVSS Score:** 8.5 (High)
- **Location:** `gateway/platforms/api_server.py`, ResponseStore class
- **Description:** Direct string interpolation in SQLite queries
- **Evidence:**
```python
# Lines 98-106, 114-126 - response_id directly interpolated
"SELECT data FROM responses WHERE response_id = ?", (response_id,)
# While parameterized, no validation of response_id format
```
- **Remediation:** Validate response_id format, use UUID strict parsing
#### V-008: CORS Misconfiguration in API Server
- **CVSS Score:** 8.2 (High)
- **Location:** `gateway/platforms/api_server.py`, cors_middleware
- **Description:** Wildcard CORS allowed with credentials
- **Evidence:**
```python
# Line 324-328: "*" in origins allows any domain
if "*" in self._cors_origins:
headers["Access-Control-Allow-Origin"] = "*"
```
- **Impact:** Cross-origin attacks, credential theft via malicious websites
- **Remediation:** Never allow "*" with credentials, implement strict origin validation
#### V-009: Authentication Bypass in API Key Check
- **CVSS Score:** 8.1 (High)
- **Location:** `gateway/platforms/api_server.py`, `_check_auth()`
- **Description:** Empty API key configuration allows all requests
- **Evidence:**
```python
# Line 360-361: No key configured = allow all
if not self._api_key:
return None # No key configured — allow all
```
- **Impact:** Unauthorized API access when key not explicitly set
- **Remediation:** Require explicit auth configuration, fail-closed default
#### V-010: Code Injection via Browser CDP Override
- **CVSS Score:** 8.4 (High)
- **Location:** `tools/browser_tool.py`, `_resolve_cdp_override()`
- **Description:** User-controlled CDP URL fetched without validation
- **Evidence:**
```python
# Line 195: requests.get(version_url) without URL validation
response = requests.get(version_url, timeout=10)
```
- **Impact:** SSRF, internal service exploitation
- **Remediation:** Strict URL allowlisting, validate scheme/host
#### V-011: Skills Guard Bypass via Obfuscation
- **CVSS Score:** 7.8 (High)
- **Location:** `tools/skills_guard.py`, THREAT_PATTERNS
- **Description:** Regex-based detection can be bypassed with encoding tricks
- **Evidence:** Patterns don't cover all Unicode variants, case variations, or encoding tricks
- **Impact:** Malicious skills installation, code execution
- **Remediation:** Normalize input before scanning, add AST-based analysis
#### V-012: Privilege Escalation via Docker Socket Mount
- **CVSS Score:** 8.7 (High)
- **Location:** `tools/environments/docker.py`, volume mounting
- **Description:** User-configured volumes can mount Docker socket
- **Evidence:**
```python
# Line 267: volume_args extends with user-controlled vol
volume_args.extend(["-v", vol])
```
- **Impact:** Container escape, host compromise
- **Remediation:** Blocklist sensitive paths, validate all mount points
#### V-013: Information Disclosure via Error Messages
- **CVSS Score:** 7.5 (High)
- **Location:** Multiple files across codebase
- **Description:** Detailed error messages expose internal paths, versions, configurations
- **Evidence:** File paths, environment details in exception messages
- **Impact:** Information gathering for targeted attacks
- **Remediation:** Sanitize error messages in production, log details internally only
#### V-014: Session Fixation in OAuth Flow
- **CVSS Score:** 7.6 (High)
- **Location:** `tools/mcp_oauth.py`, `_wait_for_callback()`
- **Description:** State parameter not validated against session
- **Evidence:** Line 186: state returned but not verified against initial value
- **Impact:** OAuth session hijacking
- **Remediation:** Cryptographically verify state parameter
#### V-015: Race Condition in File Operations
- **CVSS Score:** 7.4 (High)
- **Location:** `tools/file_operations.py`, `ShellFileOperations`
- **Description:** Time-of-check to time-of-use vulnerabilities in file access
- **Impact:** Privilege escalation, unauthorized file access
- **Remediation:** Use file descriptors, avoid path-based operations
#### V-016: Insufficient Rate Limiting
- **CVSS Score:** 7.3 (High)
- **Location:** `gateway/platforms/api_server.py`, `gateway/run.py`
- **Description:** No rate limiting on API endpoints
- **Impact:** DoS, brute force attacks, resource exhaustion
- **Remediation:** Implement per-IP and per-user rate limiting
#### V-017: Insecure Temporary File Creation
- **CVSS Score:** 7.2 (High)
- **Location:** `tools/code_execution_tool.py`, `tools/credential_files.py`
- **Description:** Predictable temp file paths, potential symlink attacks
- **Evidence:**
```python
# code_execution_tool.py line 388
tmpdir = tempfile.mkdtemp(prefix="hermes_sandbox_")
# Predictable naming scheme
```
- **Impact:** Local privilege escalation via symlink attacks
- **Remediation:** Use tempfile with proper permissions, random suffixes
---
### MEDIUM SEVERITY (CVSS 4.0-6.9)
#### V-018: Weak Approval Pattern Detection
- **CVSS Score:** 6.5 (Medium)
- **Location:** `tools/approval.py`, DANGEROUS_PATTERNS
- **Description:** Pattern list doesn't cover all dangerous command variants
- **Impact:** Unauthorized dangerous command execution
- **Remediation:** Expand patterns, add behavioral analysis
#### V-019: Insecure File Permissions on Credentials
- **CVSS Score:** 6.4 (Medium)
- **Location:** `tools/credential_files.py`, `tools/mcp_oauth.py`
- **Description:** Credential files may have overly permissive permissions
- **Evidence:**
```python
# mcp_oauth.py line 107: chmod 0o600 but no verification
path.chmod(0o600)
```
- **Impact:** Local credential theft
- **Remediation:** Verify permissions after creation, use secure umask
#### V-020: Log Injection via Unsanitized Input
- **CVSS Score:** 5.8 (Medium)
- **Location:** Multiple logging statements across codebase
- **Description:** User-controlled data written directly to logs
- **Impact:** Log poisoning, log analysis bypass
- **Remediation:** Sanitize all logged data, use structured logging
#### V-021: XML External Entity (XXE) Risk
- **CVSS Score:** 6.2 (Medium)
- **Location:** `skills/productivity/powerpoint/scripts/office/schemas/` XML parsing
- **Description:** PowerPoint processing uses XML without explicit XXE protection
- **Impact:** File disclosure, SSRF via XML entities
- **Remediation:** Disable external entities in XML parsers
#### V-022: Unsafe YAML Loading
- **CVSS Score:** 6.1 (Medium)
- **Location:** `hermes_cli/config.py`, `tools/skills_guard.py`
- **Description:** yaml.safe_load used but custom constructors may be risky
- **Impact:** Code execution via malicious YAML
- **Remediation:** Audit all YAML loading, disable unsafe tags
#### V-023: Prototype Pollution in JavaScript Bridge
- **CVSS Score:** 5.9 (Medium)
- **Location:** `scripts/whatsapp-bridge/bridge.js`
- **Description:** Object property assignments without validation
- **Impact:** Logic bypass, potential RCE in Node context
- **Remediation:** Validate all object keys, use Map instead of Object
#### V-024: Insufficient Subagent Isolation
- **CVSS Score:** 6.3 (Medium)
- **Location:** `tools/delegate_tool.py`
- **Description:** Subagents share filesystem and network with parent
- **Impact:** Lateral movement, privilege escalation between agents
- **Remediation:** Implement stronger sandbox boundaries per subagent
#### V-025: Predictable Session IDs
- **CVSS Score:** 5.5 (Medium)
- **Location:** `gateway/session.py`, `tools/terminal_tool.py`
- **Description:** Session/task IDs use uuid4 but may be logged/predictable
- **Impact:** Session hijacking
- **Remediation:** Use cryptographically secure random, short-lived tokens
#### V-026: Missing Integrity Checks on External Binaries
- **CVSS Score:** 5.7 (Medium)
- **Location:** `tools/tirith_security.py`, auto-install process
- **Description:** Binary download with limited verification
- **Evidence:** SHA-256 verified but no code signing verification by default
- **Impact:** Supply chain compromise
- **Remediation:** Require signature verification, pin versions
#### V-027: Information Leakage in Debug Mode
- **CVSS Score:** 5.2 (Medium)
- **Location:** `tools/debug_helpers.py`, `agent/display.py`
- **Description:** Debug output may contain sensitive configuration
- **Impact:** Information disclosure
- **Remediation:** Redact secrets in all debug output
---
### LOW SEVERITY (CVSS 0.1-3.9)
#### V-028: Missing Security Headers
- **CVSS Score:** 3.7 (Low)
- **Location:** `gateway/platforms/api_server.py`
- **Description:** Some security headers missing (CSP, HSTS)
- **Remediation:** Add comprehensive security headers
#### V-029: Verbose Version Information
- **CVSS Score:** 2.3 (Low)
- **Location:** Multiple version endpoints
- **Description:** Detailed version information exposed
- **Remediation:** Minimize version disclosure
#### V-030: Unused Imports and Dead Code
- **CVSS Score:** 2.0 (Low)
- **Location:** Multiple files
- **Description:** Dead code increases attack surface
- **Remediation:** Remove unused code, regular audits
#### V-031: Weak Cryptographic Practices
- **CVSS Score:** 3.2 (Low)
- **Location:** `hermes_cli/auth.py`, token handling
- **Description:** No encryption at rest for auth tokens
- **Remediation:** Use OS keychain, encrypt sensitive data
#### V-032: Missing Input Length Validation
- **CVSS Score:** 3.5 (Low)
- **Location:** Multiple tool input handlers
- **Description:** No maximum length checks on inputs
- **Remediation:** Add length validation to all inputs
---
## 2. ATTACK SURFACE DIAGRAM
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ EXTERNAL ATTACK SURFACE │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Telegram │ │ Discord │ │ Slack │ │ Web Browser │ │
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
│ │ │ │ │ │
│ ┌──────▼───────┐ ┌──────▼───────┐ ┌──────▼───────┐ ┌──────▼───────┐ │
│ │ Gateway │──│ Gateway │──│ Gateway │──│ Gateway │ │
│ │ Adapter │ │ Adapter │ │ Adapter │ │ Adapter │ │
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
│ └─────────────────┴─────────────────┘ │ │
│ │ │ │
│ ┌──────▼───────┐ ┌──────▼───────┐ │
│ │ API Server │◄─────────────────│ Web API │ │
│ │ (HTTP) │ │ Endpoints │ │
│ └──────┬───────┘ └──────────────┘ │
│ │ │
└───────────────────────────┼───────────────────────────────────────────────┘
┌───────────────────────────┼───────────────────────────────────────────────┐
│ INTERNAL ATTACK SURFACE │
├───────────────────────────┼───────────────────────────────────────────────┤
│ │ │
│ ┌──────▼───────┐ │
│ │ AI Agent │ │
│ │ Core │ │
│ └──────┬───────┘ │
│ │ │
│ ┌─────────────────┼─────────────────┐ │
│ │ │ │ │
│ ┌────▼────┐ ┌────▼────┐ ┌────▼────┐ │
│ │ Tools │ │ Tools │ │ Tools │ │
│ │ File │ │ Terminal│ │ Web │ │
│ │ Ops │ │ Exec │ │ Tools │ │
│ └────┬────┘ └────┬────┘ └────┬────┘ │
│ │ │ │ │
│ ┌────▼────┐ ┌────▼────┐ ┌────▼────┐ │
│ │ Local │ │ Docker │ │ Browser │ │
│ │ FS │ │Sandbox │ │ Tool │ │
│ └─────────┘ └────┬────┘ └────┬────┘ │
│ │ │ │
│ ┌─────▼─────┐ ┌────▼────┐ │
│ │ Modal │ │ Cloud │ │
│ │ Cloud │ │ Browser │ │
│ └───────────┘ └─────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ CREDENTIAL STORAGE │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
│ │ │ auth.json│ │ .env │ │mcp-tokens│ │ skill │ │ │
│ │ │ (OAuth) │ │ (API Key)│ │ (OAuth) │ │ creds │ │ │
│ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────────────────┘
LEGEND:
■ Entry points (external attack surface)
■ Internal components (privilege escalation targets)
■ Credential storage (high-value targets)
■ Sandboxed environments (isolation boundaries)
```
---
## 3. MITIGATION ROADMAP
### Phase 1: Critical Fixes (Week 1-2)
| Priority | Fix | Owner | Est. Hours |
|----------|-----|-------|------------|
| P0 | Remove all shell=True subprocess calls | Security Team | 16 |
| P0 | Implement strict path sandboxing | Security Team | 12 |
| P0 | Fix secret leakage in child processes | Security Team | 8 |
| P0 | Add connection-level URL validation | Security Team | 8 |
### Phase 2: High Priority (Week 3-4)
| Priority | Fix | Owner | Est. Hours |
|----------|-----|-------|------------|
| P1 | Implement proper input validation framework | Dev Team | 20 |
| P1 | Add CORS strict mode | Dev Team | 4 |
| P1 | Fix OAuth state validation | Dev Team | 6 |
| P1 | Add rate limiting | Dev Team | 10 |
| P1 | Implement secure credential storage | Security Team | 12 |
### Phase 3: Medium Priority (Month 2)
| Priority | Fix | Owner | Est. Hours |
|----------|-----|-------|------------|
| P2 | Expand dangerous command patterns | Security Team | 6 |
| P2 | Add AST-based skill scanning | Security Team | 16 |
| P2 | Implement subagent isolation | Dev Team | 20 |
| P2 | Add comprehensive audit logging | Dev Team | 12 |
### Phase 4: Long-term Improvements (Month 3+)
| Priority | Fix | Owner | Est. Hours |
|----------|-----|-------|------------|
| P3 | Security headers hardening | Dev Team | 4 |
| P3 | Code signing verification | Security Team | 8 |
| P3 | Supply chain security | Dev Team | 12 |
| P3 | Regular security audits | Security Team | Ongoing |
---
## 4. SECURE CODING GUIDELINES
### 4.1 Command Execution
```python
# ❌ NEVER DO THIS
subprocess.run(f"ls {user_input}", shell=True)
# ✅ DO THIS
subprocess.run(["ls", user_input], shell=False)
# ✅ OR USE SHLEX
import shlex
subprocess.run(["ls"] + shlex.split(user_input), shell=False)
```
### 4.2 Path Handling
```python
# ❌ NEVER DO THIS
open(os.path.expanduser(user_path), "r")
# ✅ DO THIS
from pathlib import Path
safe_root = Path("/allowed/path").resolve()
user_path = Path(user_path).expanduser().resolve()
if not str(user_path).startswith(str(safe_root)):
raise PermissionError("Path outside sandbox")
```
### 4.3 Secret Handling
```python
# ❌ NEVER DO THIS
os.environ["API_KEY"] = user_api_key # Visible to all child processes
# ✅ DO THIS
# Use file descriptor passing or explicit whitelisting
child_env = {k: v for k, v in os.environ.items()
if k in ALLOWED_ENV_VARS}
```
### 4.4 URL Validation
```python
# ❌ NEVER DO THIS
response = requests.get(user_url)
# ✅ DO THIS
from urllib.parse import urlparse
parsed = urlparse(user_url)
if parsed.scheme not in ("http", "https"):
raise ValueError("Invalid scheme")
if parsed.hostname not in ALLOWED_HOSTS:
raise ValueError("Host not allowed")
```
### 4.5 Input Validation
```python
# Use pydantic for all user inputs
from pydantic import BaseModel, validator
class FileRequest(BaseModel):
path: str
max_size: int = 1000
@validator('path')
def validate_path(cls, v):
if '..' in v or v.startswith('/'):
raise ValueError('Invalid path')
return v
```
---
## 5. SPECIFIC SECURITY FIXES NEEDED
### Fix 1: Terminal Tool Command Injection (V-001)
```python
# CURRENT CODE (tools/terminal_tool.py ~line 457)
cmd = [self._docker_exe, "exec", "-w", work_dir, self._container_id,
"bash", "-lc", exec_command]
# SECURE FIX
cmd = [self._docker_exe, "exec", "-w", work_dir, self._container_id,
"bash", "-lc", exec_command]
# Add strict input validation before this point
if not _is_safe_command(exec_command):
raise SecurityError("Dangerous command detected")
```
### Fix 2: File Operations Path Traversal (V-002)
```python
# CURRENT CODE (tools/file_operations.py ~line 409)
def _expand_path(self, path: str) -> str:
if path.startswith('~'):
# ... expansion logic
# SECURE FIX
def _expand_path(self, path: str) -> str:
safe_root = Path(self.cwd).resolve()
expanded = Path(path).expanduser().resolve()
if not str(expanded).startswith(str(safe_root)):
raise PermissionError(f"Path {path} outside allowed directory")
return str(expanded)
```
### Fix 3: Code Execution Environment Sanitization (V-003)
```python
# CURRENT CODE (tools/code_execution_tool.py ~lines 434-461)
_SAFE_ENV_PREFIXES = ("PATH", "HOME", "USER", ...)
_SECRET_SUBSTRINGS = ("TOKEN", "SECRET", ...)
# SECURE FIX - Whitelist approach
_ALLOWED_ENV_VARS = frozenset([
"PATH", "HOME", "USER", "LANG", "LC_ALL",
"PYTHONPATH", "TERM", "SHELL", "PWD"
])
child_env = {k: v for k, v in os.environ.items()
if k in _ALLOWED_ENV_VARS}
# Explicitly load only non-secret values
```
### Fix 4: API Server Authentication (V-009)
```python
# CURRENT CODE (gateway/platforms/api_server.py ~line 360-361)
if not self._api_key:
return None # No key configured — allow all
# SECURE FIX
if not self._api_key:
logger.error("API server started without authentication")
return web.json_response(
{"error": "Server misconfigured - auth required"},
status=500
)
```
### Fix 5: CORS Configuration (V-008)
```python
# CURRENT CODE (gateway/platforms/api_server.py ~lines 324-328)
if "*" in self._cors_origins:
headers["Access-Control-Allow-Origin"] = "*"
# SECURE FIX - Never allow wildcard with credentials
if "*" in self._cors_origins:
logger.warning("Wildcard CORS not allowed with credentials")
return None
```
### Fix 6: OAuth State Validation (V-014)
```python
# CURRENT CODE (tools/mcp_oauth.py ~line 186)
code, state = await _wait_for_callback()
# SECURE FIX
stored_state = get_stored_state()
if state != stored_state:
raise SecurityError("OAuth state mismatch - possible CSRF attack")
```
### Fix 7: Docker Volume Mount Validation (V-012)
```python
# CURRENT CODE (tools/environments/docker.py ~line 267)
volume_args.extend(["-v", vol])
# SECURE FIX
_BLOCKED_PATHS = ['/var/run/docker.sock', '/proc', '/sys', ...]
if any(blocked in vol for blocked in _BLOCKED_PATHS):
raise SecurityError(f"Volume mount {vol} not allowed")
volume_args.extend(["-v", vol])
```
### Fix 8: Debug Output Redaction (V-027)
```python
# Add to all debug logging
from agent.redact import redact_sensitive_text
logger.debug(redact_sensitive_text(debug_message))
```
### Fix 9: Input Length Validation
```python
# Add to all tool entry points
MAX_INPUT_LENGTH = 10000
if len(user_input) > MAX_INPUT_LENGTH:
raise ValueError(f"Input exceeds maximum length of {MAX_INPUT_LENGTH}")
```
### Fix 10: Session ID Entropy
```python
# CURRENT CODE - uses uuid4
import uuid
session_id = str(uuid.uuid4())
# SECURE FIX - use secrets module
import secrets
session_id = secrets.token_urlsafe(32)
```
### Fix 11-20: Additional Required Fixes
11. **Add CSRF protection** to all state-changing operations
12. **Implement request signing** for internal service communication
13. **Add certificate pinning** for external API calls
14. **Implement proper key rotation** for auth tokens
15. **Add anomaly detection** for unusual command patterns
16. **Implement network segmentation** for sandbox environments
17. **Add hardware security module (HSM) support** for key storage
18. **Implement behavioral analysis** for skill code
19. **Add automated vulnerability scanning** to CI/CD pipeline
20. **Implement incident response procedures** for security events
---
## 6. SECURITY RECOMMENDATIONS
### Immediate Actions (Within 24 hours)
1. Disable gateway API server if not required
2. Enable HERMES_YOLO_MODE only for trusted users
3. Review all installed skills from community sources
4. Enable comprehensive audit logging
### Short-term Actions (Within 1 week)
1. Deploy all P0 fixes
2. Implement monitoring for suspicious command patterns
3. Conduct security training for developers
4. Establish security review process for new features
### Long-term Actions (Within 1 month)
1. Implement comprehensive security testing
2. Establish bug bounty program
3. Regular third-party security audits
4. Achieve SOC 2 compliance
---
## 7. COMPLIANCE MAPPING
| Vulnerability | OWASP Top 10 | CWE | NIST 800-53 |
|---------------|--------------|-----|-------------|
| V-001 (Command Injection) | A03:2021 - Injection | CWE-78 | SI-10 |
| V-002 (Path Traversal) | A01:2021 - Broken Access Control | CWE-22 | AC-3 |
| V-003 (Secret Leakage) | A07:2021 - Auth Failures | CWE-200 | SC-28 |
| V-005 (SSRF) | A10:2021 - SSRF | CWE-918 | SC-7 |
| V-008 (CORS) | A05:2021 - Security Misconfig | CWE-942 | AC-4 |
| V-011 (Skills Bypass) | A08:2021 - Integrity Failures | CWE-353 | SI-7 |
---
## APPENDIX A: TESTING RECOMMENDATIONS
### Security Test Cases
1. Command injection with `; rm -rf /`
2. Path traversal with `../../../etc/passwd`
3. SSRF with `http://169.254.169.254/latest/meta-data/`
4. Secret exfiltration via environment variables
5. OAuth flow manipulation
6. Rate limiting bypass
7. Session fixation attacks
8. Privilege escalation via sudo
---
**Report End**
*This audit represents a point-in-time assessment. Security is an ongoing process requiring continuous monitoring and improvement.*

View File

@@ -1,488 +0,0 @@
# SECURITY FIXES CHECKLIST
## 20+ Specific Security Fixes Required
This document provides a detailed checklist of all security fixes identified in the comprehensive audit.
---
## CRITICAL FIXES (Must implement immediately)
### Fix 1: Remove shell=True from subprocess calls
**File:** `tools/terminal_tool.py`
**Line:** ~457
**CVSS:** 9.8
```python
# BEFORE
subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, ...)
# AFTER
# Validate command first
if not is_safe_command(exec_command):
raise SecurityError("Dangerous command detected")
subprocess.Popen(cmd_list, shell=False, ...) # Pass as list
```
---
### Fix 2: Implement path sandbox validation
**File:** `tools/file_operations.py`
**Lines:** 409-420
**CVSS:** 9.1
```python
# BEFORE
def _expand_path(self, path: str) -> str:
if path.startswith('~'):
return os.path.expanduser(path)
return path
# AFTER
def _expand_path(self, path: str) -> Path:
safe_root = Path(self.cwd).resolve()
expanded = Path(path).expanduser().resolve()
if not str(expanded).startswith(str(safe_root)):
raise PermissionError(f"Path {path} outside allowed directory")
return expanded
```
---
### Fix 3: Environment variable sanitization
**File:** `tools/code_execution_tool.py`
**Lines:** 434-461
**CVSS:** 9.3
```python
# BEFORE
_SAFE_ENV_PREFIXES = ("PATH", "HOME", "USER", ...)
_SECRET_SUBSTRINGS = ("TOKEN", "SECRET", ...)
# AFTER
_ALLOWED_ENV_VARS = frozenset([
"PATH", "HOME", "USER", "LANG", "LC_ALL",
"TERM", "SHELL", "PWD", "PYTHONPATH"
])
child_env = {k: v for k, v in os.environ.items()
if k in _ALLOWED_ENV_VARS}
```
---
### Fix 4: Secure sudo password handling
**File:** `tools/terminal_tool.py`
**Line:** 275
**CVSS:** 9.0
```python
# BEFORE
exec_command = f"printf '%s\\n' {shlex.quote(sudo_stdin.rstrip())} | {exec_command}"
# AFTER
# Use file descriptor passing instead of command line
with tempfile.NamedTemporaryFile(mode='w', delete=False) as f:
f.write(sudo_stdin)
pass_file = f.name
os.chmod(pass_file, 0o600)
exec_command = f"cat {pass_file} | {exec_command}"
# Clean up after execution
```
---
### Fix 5: Connection-level URL validation
**File:** `tools/url_safety.py`
**Lines:** 50-96
**CVSS:** 9.4
```python
# AFTER - Add to is_safe_url()
# After DNS resolution, verify IP is not in private range
def _validate_connection_ip(hostname: str) -> bool:
try:
addr = socket.getaddrinfo(hostname, None)
for a in addr:
ip = ipaddress.ip_address(a[4][0])
if ip.is_private or ip.is_loopback or ip.is_reserved:
return False
return True
except:
return False
```
---
## HIGH PRIORITY FIXES
### Fix 6: MCP OAuth token validation
**File:** `tools/mcp_oauth.py`
**Lines:** 66-89
**CVSS:** 8.8
```python
# AFTER
async def get_tokens(self):
data = self._read_json(self._tokens_path())
if not data:
return None
# Add schema validation
if not self._validate_token_schema(data):
logger.error("Invalid token schema, deleting corrupted tokens")
self.remove()
return None
return OAuthToken(**data)
```
---
### Fix 7: API Server SQL injection prevention
**File:** `gateway/platforms/api_server.py`
**Lines:** 98-126
**CVSS:** 8.5
```python
# AFTER
import uuid
def _validate_response_id(self, response_id: str) -> bool:
"""Validate response_id format to prevent injection."""
try:
uuid.UUID(response_id.split('-')[0], version=4)
return True
except (ValueError, IndexError):
return False
```
---
### Fix 8: CORS strict validation
**File:** `gateway/platforms/api_server.py`
**Lines:** 324-328
**CVSS:** 8.2
```python
# AFTER
if "*" in self._cors_origins:
logger.error("Wildcard CORS not allowed with credentials")
return None # Reject wildcard with credentials
```
---
### Fix 9: Require explicit API key
**File:** `gateway/platforms/api_server.py`
**Lines:** 360-361
**CVSS:** 8.1
```python
# AFTER
if not self._api_key:
logger.error("API server started without authentication")
return web.json_response(
{"error": "Server authentication not configured"},
status=500
)
```
---
### Fix 10: CDP URL validation
**File:** `tools/browser_tool.py`
**Lines:** 195-208
**CVSS:** 8.4
```python
# AFTER
def _resolve_cdp_override(self, cdp_url: str) -> str:
parsed = urlparse(cdp_url)
if parsed.scheme not in ('ws', 'wss', 'http', 'https'):
raise ValueError("Invalid CDP scheme")
if parsed.hostname not in self._allowed_cdp_hosts:
raise ValueError("CDP host not in allowlist")
return cdp_url
```
---
### Fix 11: Skills guard normalization
**File:** `tools/skills_guard.py`
**Lines:** 82-484
**CVSS:** 7.8
```python
# AFTER - Add to scan_skill()
def normalize_for_scanning(content: str) -> str:
"""Normalize content to detect obfuscated threats."""
# Normalize Unicode
content = unicodedata.normalize('NFKC', content)
# Normalize case
content = content.lower()
# Remove common obfuscation
content = content.replace('\\x', '')
content = content.replace('\\u', '')
return content
```
---
### Fix 12: Docker volume validation
**File:** `tools/environments/docker.py`
**Line:** 267
**CVSS:** 8.7
```python
# AFTER
_BLOCKED_PATHS = ['/var/run/docker.sock', '/proc', '/sys', '/dev']
for vol in volumes:
if any(blocked in vol for blocked in _BLOCKED_PATHS):
raise SecurityError(f"Volume mount {vol} blocked")
volume_args.extend(["-v", vol])
```
---
### Fix 13: Secure error messages
**File:** Multiple files
**CVSS:** 7.5
```python
# AFTER - Add to all exception handlers
try:
operation()
except Exception as e:
logger.error(f"Error: {e}", exc_info=True) # Full details for logs
raise UserError("Operation failed") # Generic for user
```
---
### Fix 14: OAuth state validation
**File:** `tools/mcp_oauth.py`
**Line:** 186
**CVSS:** 7.6
```python
# AFTER
code, state = await _wait_for_callback()
stored_state = storage.get_state()
if not hmac.compare_digest(state, stored_state):
raise SecurityError("OAuth state mismatch - possible CSRF")
```
---
### Fix 15: File operation race condition fix
**File:** `tools/file_operations.py`
**CVSS:** 7.4
```python
# AFTER
import fcntl
def safe_file_access(path: Path):
fd = os.open(path, os.O_RDONLY)
try:
fcntl.flock(fd, fcntl.LOCK_SH)
# Perform operations on fd, not path
return os.read(fd, size)
finally:
fcntl.flock(fd, fcntl.LOCK_UN)
os.close(fd)
```
---
### Fix 16: Add rate limiting
**File:** `gateway/platforms/api_server.py`
**CVSS:** 7.3
```python
# AFTER - Add middleware
from aiohttp_limiter import Limiter
limiter = Limiter(
rate=100, # requests
per=60, # per minute
key_func=lambda req: req.remote
)
@app.middleware
async def rate_limit_middleware(request, handler):
if not limiter.is_allowed(request):
return web.json_response(
{"error": "Rate limit exceeded"},
status=429
)
return await handler(request)
```
---
### Fix 17: Secure temp file creation
**File:** `tools/code_execution_tool.py`
**Line:** 388
**CVSS:** 7.2
```python
# AFTER
import tempfile
import os
fd, tmpdir = tempfile.mkstemp(prefix="hermes_sandbox_", suffix=".tmp")
os.chmod(tmpdir, 0o700) # Owner only
os.close(fd)
# Use tmpdir securely
```
---
## MEDIUM PRIORITY FIXES
### Fix 18: Expand dangerous patterns
**File:** `tools/approval.py`
**Lines:** 40-78
**CVSS:** 6.5
Add patterns:
```python
(r'\bcurl\s+.*\|\s*sh\b', "pipe remote content to shell"),
(r'\bwget\s+.*\|\s*bash\b', "pipe remote content to shell"),
(r'python\s+-c\s+.*import\s+os', "python os import"),
(r'perl\s+-e\s+.*system', "perl system call"),
```
---
### Fix 19: Credential file permissions
**File:** `tools/credential_files.py`, `tools/mcp_oauth.py`
**CVSS:** 6.4
```python
# AFTER
def _write_json(path: Path, data: dict) -> None:
path.write_text(json.dumps(data, indent=2), encoding="utf-8")
path.chmod(0o600)
# Verify permissions were set
stat = path.stat()
if stat.st_mode & 0o077:
raise SecurityError("Failed to set restrictive permissions")
```
---
### Fix 20: Log sanitization
**File:** Multiple logging statements
**CVSS:** 5.8
```python
# AFTER
from agent.redact import redact_sensitive_text
# In all logging calls
logger.info(redact_sensitive_text(f"Processing {user_input}"))
```
---
## ADDITIONAL FIXES (21-32)
### Fix 21: XXE Prevention
**File:** PowerPoint XML processing
Add:
```python
from defusedxml import ElementTree as ET
# Use defusedxml instead of standard xml
```
---
### Fix 22: YAML Safe Loading Audit
**File:** `hermes_cli/config.py`
Audit all yaml.safe_load calls for custom constructors.
---
### Fix 23: Prototype Pollution Fix
**File:** `scripts/whatsapp-bridge/bridge.js`
Use Map instead of Object for user-controlled keys.
---
### Fix 24: Subagent Isolation
**File:** `tools/delegate_tool.py`
Implement filesystem namespace isolation.
---
### Fix 25: Secure Session IDs
**File:** `gateway/session.py`
Use secrets.token_urlsafe(32) instead of uuid4.
---
### Fix 26: Binary Integrity Checks
**File:** `tools/tirith_security.py`
Require GPG signature verification.
---
### Fix 27: Debug Output Redaction
**File:** `tools/debug_helpers.py`
Apply redact_sensitive_text to all debug output.
---
### Fix 28: Security Headers
**File:** `gateway/platforms/api_server.py`
Add:
```python
"Content-Security-Policy": "default-src 'self'",
"Strict-Transport-Security": "max-age=31536000",
```
---
### Fix 29: Version Information Minimization
**File:** Version endpoints
Return minimal version information publicly.
---
### Fix 30: Dead Code Removal
**File:** Multiple
Remove unused imports and functions.
---
### Fix 31: Token Encryption at Rest
**File:** `hermes_cli/auth.py`
Use OS keychain or encrypt auth.json.
---
### Fix 32: Input Length Validation
**File:** All tool entry points
Add MAX_INPUT_LENGTH checks everywhere.
---
## IMPLEMENTATION VERIFICATION
### Testing Requirements
- [ ] All fixes have unit tests
- [ ] Security regression tests pass
- [ ] Fuzzing shows no new vulnerabilities
- [ ] Penetration test completed
- [ ] Code review by security team
### Sign-off Required
- [ ] Security Team Lead
- [ ] Engineering Manager
- [ ] QA Lead
- [ ] DevOps Lead
---
**Last Updated:** March 30, 2026
**Next Review:** After all P0/P1 fixes completed

View File

@@ -1,359 +0,0 @@
# SECURITY MITIGATION ROADMAP
## Hermes Agent Security Remediation Plan
**Version:** 1.0
**Date:** March 30, 2026
**Status:** Draft for Implementation
---
## EXECUTIVE SUMMARY
This roadmap provides a structured approach to addressing the 32 security vulnerabilities identified in the comprehensive security audit. The plan is organized into four phases, prioritizing fixes by risk and impact.
---
## PHASE 1: CRITICAL FIXES (Week 1-2)
**Target:** Eliminate all CVSS 9.0+ vulnerabilities
### 1.1 Remove shell=True Subprocess Calls (V-001)
**Owner:** Security Team Lead
**Estimated Effort:** 16 hours
**Priority:** P0
#### Tasks:
- [ ] Audit all subprocess calls in codebase
- [ ] Replace shell=True with argument lists
- [ ] Implement shlex.quote for necessary string interpolation
- [ ] Add input validation wrappers
#### Files to Modify:
- `tools/terminal_tool.py`
- `tools/file_operations.py`
- `tools/environments/docker.py`
- `tools/environments/modal.py`
- `tools/environments/ssh.py`
- `tools/environments/singularity.py`
#### Testing:
- [ ] Unit tests for all command execution paths
- [ ] Fuzzing with malicious inputs
- [ ] Penetration testing
---
### 1.2 Implement Strict Path Sandboxing (V-002)
**Owner:** Security Team Lead
**Estimated Effort:** 12 hours
**Priority:** P0
#### Tasks:
- [ ] Create PathValidator class
- [ ] Implement canonical path resolution
- [ ] Add path traversal detection
- [ ] Enforce sandbox root boundaries
#### Implementation:
```python
class PathValidator:
def __init__(self, sandbox_root: Path):
self.sandbox_root = sandbox_root.resolve()
def validate(self, user_path: str) -> Path:
expanded = Path(user_path).expanduser().resolve()
if not str(expanded).startswith(str(self.sandbox_root)):
raise SecurityError("Path outside sandbox")
return expanded
```
#### Files to Modify:
- `tools/file_operations.py`
- `tools/file_tools.py`
- All environment implementations
---
### 1.3 Fix Secret Leakage in Child Processes (V-003)
**Owner:** Security Engineer
**Estimated Effort:** 8 hours
**Priority:** P0
#### Tasks:
- [ ] Create environment variable whitelist
- [ ] Implement secret detection patterns
- [ ] Add env var scrubbing for child processes
- [ ] Audit credential file mounting
#### Whitelist Approach:
```python
_ALLOWED_ENV_VARS = frozenset([
"PATH", "HOME", "USER", "LANG", "LC_ALL",
"TERM", "SHELL", "PWD", "OLDPWD",
"PYTHONPATH", "PYTHONHOME", "PYTHONNOUSERSITE",
"DISPLAY", "XDG_SESSION_TYPE", # GUI apps
])
def sanitize_environment():
return {k: v for k, v in os.environ.items()
if k in _ALLOWED_ENV_VARS}
```
---
### 1.4 Add Connection-Level URL Validation (V-005)
**Owner:** Security Engineer
**Estimated Effort:** 8 hours
**Priority:** P0
#### Tasks:
- [ ] Implement egress proxy option
- [ ] Add connection-level IP validation
- [ ] Validate redirect targets
- [ ] Block private IP ranges at socket level
---
## PHASE 2: HIGH PRIORITY (Week 3-4)
**Target:** Address all CVSS 7.0-8.9 vulnerabilities
### 2.1 Implement Input Validation Framework (V-006, V-007)
**Owner:** Senior Developer
**Estimated Effort:** 20 hours
**Priority:** P1
#### Tasks:
- [ ] Create Pydantic models for all tool inputs
- [ ] Implement length validation
- [ ] Add character allowlisting
- [ ] Create validation decorators
---
### 2.2 Fix CORS Configuration (V-008)
**Owner:** Backend Developer
**Estimated Effort:** 4 hours
**Priority:** P1
#### Changes:
- Remove wildcard support when credentials enabled
- Implement strict origin validation
- Add origin allowlist configuration
---
### 2.3 Fix Authentication Bypass (V-009)
**Owner:** Backend Developer
**Estimated Effort:** 4 hours
**Priority:** P1
#### Changes:
```python
# Fail-closed default
if not self._api_key:
logger.error("API server requires authentication")
return web.json_response(
{"error": "Authentication required"},
status=401
)
```
---
### 2.4 Fix OAuth State Validation (V-014)
**Owner:** Security Engineer
**Estimated Effort:** 6 hours
**Priority:** P1
#### Tasks:
- Store state parameter in session
- Cryptographically verify callback state
- Implement state expiration
---
### 2.5 Add Rate Limiting (V-016)
**Owner:** Backend Developer
**Estimated Effort:** 10 hours
**Priority:** P1
#### Implementation:
- Per-IP rate limiting: 100 requests/minute
- Per-user rate limiting: 1000 requests/hour
- Endpoint-specific limits
- Sliding window algorithm
---
### 2.6 Secure Credential Storage (V-019, V-031)
**Owner:** Security Engineer
**Estimated Effort:** 12 hours
**Priority:** P1
#### Tasks:
- Implement OS keychain integration
- Add file encryption at rest
- Implement secure key derivation
- Add access audit logging
---
## PHASE 3: MEDIUM PRIORITY (Month 2)
**Target:** Address CVSS 4.0-6.9 vulnerabilities
### 3.1 Expand Dangerous Command Patterns (V-018)
**Owner:** Security Engineer
**Estimated Effort:** 6 hours
**Priority:** P2
#### Add Patterns:
- More encoding variants (base64, hex, unicode)
- Alternative shell syntaxes
- Indirect command execution
- Environment variable abuse
---
### 3.2 Add AST-Based Skill Scanning (V-011)
**Owner:** Security Engineer
**Estimated Effort:** 16 hours
**Priority:** P2
#### Implementation:
- Parse Python code to AST
- Detect dangerous function calls
- Analyze import statements
- Check for obfuscation patterns
---
### 3.3 Implement Subagent Isolation (V-024)
**Owner:** Senior Developer
**Estimated Effort:** 20 hours
**Priority:** P2
#### Tasks:
- Create isolated filesystem per subagent
- Implement network namespace isolation
- Add resource limits
- Implement subagent-to-subagent communication restrictions
---
### 3.4 Add Comprehensive Audit Logging (V-013, V-020, V-027)
**Owner:** DevOps Engineer
**Estimated Effort:** 12 hours
**Priority:** P2
#### Requirements:
- Log all tool invocations
- Log all authentication events
- Log configuration changes
- Implement log integrity protection
- Add SIEM integration hooks
---
## PHASE 4: LONG-TERM IMPROVEMENTS (Month 3+)
### 4.1 Security Headers Hardening (V-028)
**Owner:** Backend Developer
**Estimated Effort:** 4 hours
Add headers:
- Content-Security-Policy
- Strict-Transport-Security
- X-Frame-Options
- X-XSS-Protection
---
### 4.2 Code Signing Verification (V-026)
**Owner:** Security Engineer
**Estimated Effort:** 8 hours
- Require GPG signatures for binaries
- Implement signature verification
- Pin trusted signing keys
---
### 4.3 Supply Chain Security
**Owner:** DevOps Engineer
**Estimated Effort:** 12 hours
- Implement dependency scanning
- Add SLSA compliance
- Use private package registry
- Implement SBOM generation
---
### 4.4 Automated Security Testing
**Owner:** QA Lead
**Estimated Effort:** 16 hours
- Integrate SAST tools (Semgrep, Bandit)
- Add DAST to CI/CD
- Implement fuzzing
- Add security regression tests
---
## IMPLEMENTATION TRACKING
| Week | Deliverables | Owner | Status |
|------|-------------|-------|--------|
| 1 | P0 Fixes: V-001, V-002 | Security Team | ⏳ Planned |
| 1 | P0 Fixes: V-003, V-005 | Security Team | ⏳ Planned |
| 2 | P0 Testing & Validation | QA Team | ⏳ Planned |
| 3 | P1 Fixes: V-006 through V-010 | Dev Team | ⏳ Planned |
| 3 | P1 Fixes: V-014, V-016 | Dev Team | ⏳ Planned |
| 4 | P1 Testing & Documentation | QA/Doc Team | ⏳ Planned |
| 5-8 | P2 Fixes Implementation | Dev Team | ⏳ Planned |
| 9-12 | P3/P4 Long-term Improvements | All Teams | ⏳ Planned |
---
## SUCCESS METRICS
### Security Metrics
- [ ] Zero CVSS 9.0+ vulnerabilities
- [ ] < 5 CVSS 7.0-8.9 vulnerabilities
- [ ] 100% of subprocess calls without shell=True
- [ ] 100% path validation coverage
- [ ] 100% input validation on tool entry points
### Compliance Metrics
- [ ] OWASP Top 10 compliance
- [ ] CWE coverage > 90%
- [ ] Security test coverage > 80%
---
## RISK ACCEPTANCE
| Vulnerability | Risk | Justification | Approver |
|--------------|------|---------------|----------|
| V-029 (Version Info) | Low | Required for debugging | TBD |
| V-030 (Dead Code) | Low | Cleanup in next refactor | TBD |
---
## APPENDIX: TOOLS AND RESOURCES
### Recommended Security Tools
1. **SAST:** Semgrep, Bandit, Pylint-security
2. **DAST:** OWASP ZAP, Burp Suite
3. **Dependency:** Safety, Snyk, Dependabot
4. **Secrets:** GitLeaks, TruffleHog
5. **Fuzzing:** Atheris, Hypothesis
### Training Resources
- OWASP Top 10 for Python
- Secure Coding in Python (SANS)
- AWS Security Best Practices
---
**Document Owner:** Security Team
**Review Cycle:** Monthly during remediation, Quarterly post-completion

View File

@@ -1,509 +0,0 @@
# Hermes Agent - Testing Infrastructure Deep Analysis
## Executive Summary
The hermes-agent project has a **comprehensive test suite** with **373 test files** containing approximately **4,300+ test functions**. The tests are organized into 10 subdirectories covering all major components.
---
## 1. Test Suite Structure & Statistics
### 1.1 Directory Breakdown
| Directory | Test Files | Focus Area |
|-----------|------------|------------|
| `tests/tools/` | 86 | Tool implementations, file operations, environments |
| `tests/gateway/` | 96 | Platform integrations (Discord, Telegram, Slack, etc.) |
| `tests/hermes_cli/` | 48 | CLI commands, configuration, setup flows |
| `tests/agent/` | 16 | Core agent logic, prompt building, model adapters |
| `tests/integration/` | 8 | End-to-end integration tests |
| `tests/acp/` | 8 | Agent Communication Protocol |
| `tests/cron/` | 3 | Cron job scheduling |
| `tests/skills/` | 5 | Skill management |
| `tests/honcho_integration/` | 5 | Honcho memory integration |
| `tests/fakes/` | 2 | Test fixtures and fake servers |
| **Total** | **373** | **~4,311 test functions** |
### 1.2 Test Classification
**Unit Tests:** ~95% (3,600+)
**Integration Tests:** ~5% (marked with `@pytest.mark.integration`)
**Async Tests:** ~679 tests use `@pytest.mark.asyncio`
### 1.3 Largest Test Files (by line count)
1. `tests/test_run_agent.py` - 3,329 lines (212 tests) - Core agent logic
2. `tests/tools/test_mcp_tool.py` - 2,902 lines (147 tests) - MCP protocol
3. `tests/gateway/test_voice_command.py` - 2,632 lines - Voice features
4. `tests/gateway/test_feishu.py` - 2,580 lines - Feishu platform
5. `tests/gateway/test_api_server.py` - 1,503 lines - API server
---
## 2. Coverage Heat Map - Critical Gaps Identified
### 2.1 NO TEST COVERAGE (Red Zone)
#### Agent Module Gaps:
- `agent/copilot_acp_client.py` - Copilot integration (0 tests)
- `agent/gemini_adapter.py` - Google Gemini model support (0 tests)
- `agent/knowledge_ingester.py` - Knowledge ingestion (0 tests)
- `agent/meta_reasoning.py` - Meta-reasoning capabilities (0 tests)
- `agent/skill_utils.py` - Skill utilities (0 tests)
- `agent/trajectory.py` - Trajectory management (0 tests)
#### Tools Module Gaps:
- `tools/browser_tool.py` - Browser automation (0 tests)
- `tools/code_execution_tool.py` - Code execution (0 tests)
- `tools/gitea_client.py` - Gitea integration (0 tests)
- `tools/image_generation_tool.py` - Image generation (0 tests)
- `tools/neutts_synth.py` - Neural TTS (0 tests)
- `tools/openrouter_client.py` - OpenRouter API (0 tests)
- `tools/session_search_tool.py` - Session search (0 tests)
- `tools/terminal_tool.py` - Terminal operations (0 tests)
- `tools/tts_tool.py` - Text-to-speech (0 tests)
- `tools/web_tools.py` - Web tools core (0 tests)
#### Gateway Module Gaps:
- `gateway/run.py` - Gateway runner (0 tests)
- `gateway/stream_consumer.py` - Stream consumption (0 tests)
#### Root-Level Gaps:
- `hermes_constants.py` - Constants (0 tests)
- `hermes_time.py` - Time utilities (0 tests)
- `mini_swe_runner.py` - SWE runner (0 tests)
- `rl_cli.py` - RL CLI (0 tests)
- `utils.py` - Utilities (0 tests)
### 2.2 LIMITED COVERAGE (Yellow Zone)
- `agent/models_dev.py` - Only 19 tests for complex model routing
- `agent/smart_model_routing.py` - Only 6 tests
- `tools/approval.py` - 2 test files but complex logic
- `tools/skills_guard.py` - Security-critical, needs more coverage
### 2.3 GOOD COVERAGE (Green Zone)
- `agent/anthropic_adapter.py` - 97 tests (comprehensive)
- `agent/prompt_builder.py` - 108 tests (excellent)
- `tools/mcp_tool.py` - 147 tests (very comprehensive)
- `tools/file_tools.py` - Multiple test files
- `gateway/discord.py` - 11 test files covering various aspects
- `gateway/telegram.py` - 10 test files
- `gateway/session.py` - 15 test files
---
## 3. Test Patterns Analysis
### 3.1 Fixtures Architecture
**Global Fixtures (`conftest.py`):**
- `_isolate_hermes_home` - Isolates HERMES_HOME to temp directory (autouse)
- `_ensure_current_event_loop` - Event loop management for sync tests (autouse)
- `_enforce_test_timeout` - 30-second timeout per test (autouse)
- `tmp_dir` - Temporary directory fixture
- `mock_config` - Minimal hermes config for unit tests
**Common Patterns:**
```python
# Isolation pattern
@pytest.fixture(autouse=True)
def isolate_env(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
# Mock client pattern
@pytest.fixture
def mock_agent():
with patch("run_agent.OpenAI") as mock:
yield mock
```
### 3.2 Mock Usage Statistics
- **~12,468 mock/patch usages** across the test suite
- Heavy use of `unittest.mock.patch` and `MagicMock`
- `AsyncMock` used for async function mocking
- `SimpleNamespace` for creating mock API response objects
### 3.3 Test Organization Patterns
**Class-Based Organization:**
- 1,532 test classes identified
- Grouped by functionality: `Test<Feature><Scenario>`
- Example: `TestSanitizeApiMessages`, `TestContextPressureFlags`
**Function-Based Organization:**
- Used for simpler test files
- Naming: `test_<feature>_<scenario>`
### 3.4 Async Test Patterns
```python
@pytest.mark.asyncio
async def test_async_function():
result = await async_function()
assert result == expected
```
---
## 4. 20 New Test Recommendations (Priority Order)
### Critical Priority (Security/Risk)
1. **Browser Tool Security Tests** (`tools/browser_tool.py`)
- Test sandbox escape prevention
- Test malicious script blocking
- Test content security policy enforcement
2. **Code Execution Sandbox Tests** (`tools/code_execution_tool.py`)
- Test resource limits (CPU, memory)
- Test dangerous import blocking
- Test timeout enforcement
- Test filesystem access restrictions
3. **Terminal Tool Safety Tests** (`tools/terminal_tool.py`)
- Test dangerous command blocking
- Test command injection prevention
- Test environment variable sanitization
4. **OpenRouter Client Tests** (`tools/openrouter_client.py`)
- Test API key handling
- Test rate limit handling
- Test error response parsing
### High Priority (Core Functionality)
5. **Gemini Adapter Tests** (`agent/gemini_adapter.py`)
- Test message format conversion
- Test tool call normalization
- Test streaming response handling
6. **Copilot ACP Client Tests** (`agent/copilot_acp_client.py`)
- Test authentication flow
- Test session management
- Test message passing
7. **Knowledge Ingester Tests** (`agent/knowledge_ingester.py`)
- Test document parsing
- Test embedding generation
- Test knowledge retrieval
8. **Stream Consumer Tests** (`gateway/stream_consumer.py`)
- Test backpressure handling
- Test reconnection logic
- Test message ordering guarantees
### Medium Priority (Integration/Features)
9. **Web Tools Core Tests** (`tools/web_tools.py`)
- Test search result parsing
- Test content extraction
- Test error handling for unavailable services
10. **Image Generation Tool Tests** (`tools/image_generation_tool.py`)
- Test prompt filtering
- Test image format handling
- Test provider failover
11. **Gitea Client Tests** (`tools/gitea_client.py`)
- Test repository operations
- Test webhook handling
- Test authentication
12. **Session Search Tool Tests** (`tools/session_search_tool.py`)
- Test query parsing
- Test result ranking
- Test pagination
13. **Meta Reasoning Tests** (`agent/meta_reasoning.py`)
- Test strategy selection
- Test reflection generation
- Test learning from failures
14. **TTS Tool Tests** (`tools/tts_tool.py`)
- Test voice selection
- Test audio format conversion
- Test streaming playback
15. **Neural TTS Tests** (`tools/neutts_synth.py`)
- Test voice cloning safety
- Test audio quality validation
- Test resource cleanup
### Lower Priority (Utilities)
16. **Hermes Constants Tests** (`hermes_constants.py`)
- Test constant values
- Test environment-specific overrides
17. **Time Utilities Tests** (`hermes_time.py`)
- Test timezone handling
- Test formatting functions
18. **Utils Module Tests** (`utils.py`)
- Test helper functions
- Test validation utilities
19. **Mini SWE Runner Tests** (`mini_swe_runner.py`)
- Test repository setup
- Test test execution
- Test result parsing
20. **RL CLI Tests** (`rl_cli.py`)
- Test training command parsing
- Test configuration validation
- Test checkpoint handling
---
## 5. Test Optimization Opportunities
### 5.1 Performance Issues Identified
**Large Test Files (Split Recommended):**
- `tests/test_run_agent.py` (3,329 lines) → Split into multiple files
- `tests/tools/test_mcp_tool.py` (2,902 lines) → Split by MCP feature
- `tests/test_anthropic_adapter.py` (1,219 lines) → Consider splitting
**Potential Slow Tests:**
- Integration tests with real API calls
- Tests with file I/O operations
- Tests with subprocess spawning
### 5.2 Optimization Recommendations
1. **Parallel Execution Already Configured**
- `pytest-xdist` with `-n auto` in CI
- Maintains isolation through fixtures
2. **Fixture Scope Optimization**
- Review `autouse=True` fixtures for necessity
- Consider session-scoped fixtures for expensive setup
3. **Mock External Services**
- Some integration tests still hit real APIs
- Create more fakes like `fake_ha_server.py`
4. **Test Data Management**
- Use factory pattern for test data generation
- Share test fixtures across related tests
### 5.3 CI/CD Optimizations
Current CI (`.github/workflows/tests.yml`):
- Uses `uv` for fast dependency installation
- Runs with `-n auto` for parallelization
- Ignores integration tests by default
- 10-minute timeout
**Recommended Improvements:**
1. Add test duration reporting (`--durations=10`)
2. Add coverage reporting
3. Separate fast unit tests from slower integration tests
4. Add flaky test retry mechanism
---
## 6. Missing Integration Test Scenarios
### 6.1 Cross-Component Integration
1. **End-to-End Agent Flow**
- User message → Gateway → Agent → Tools → Response
- Test with real (mocked) LLM responses
2. **Multi-Platform Gateway**
- Message routing between platforms
- Session persistence across platforms
3. **Tool + Environment Integration**
- Terminal tool with different backends (local, docker, modal)
- File operations with permission checks
4. **Skill Lifecycle Integration**
- Skill installation → Registration → Execution → Update → Removal
5. **Memory + Honcho Integration**
- Memory storage → Retrieval → Context injection
### 6.2 Failure Scenario Integration Tests
1. **LLM Provider Failover**
- Primary provider down → Fallback provider
- Rate limiting handling
2. **Gateway Reconnection**
- Platform disconnect → Reconnect → Resume session
3. **Tool Execution Failures**
- Tool timeout → Retry → Fallback
- Tool error → Error handling → User notification
4. **Checkpoint Recovery**
- Crash during batch → Resume from checkpoint
- Corrupted checkpoint handling
### 6.3 Security Integration Tests
1. **Prompt Injection Across Stack**
- Gateway input → Agent processing → Tool execution
2. **Permission Escalation Prevention**
- User permissions → Tool allowlist → Execution
3. **Data Leak Prevention**
- Memory storage → Context building → Response generation
---
## 7. Performance Test Strategy
### 7.1 Load Testing Requirements
1. **Gateway Load Tests**
- Concurrent session handling
- Message throughput per platform
- Memory usage under load
2. **Agent Response Time Tests**
- End-to-end latency benchmarks
- Tool execution time budgets
- Context building performance
3. **Resource Utilization Tests**
- Memory leaks in long-running sessions
- File descriptor limits
- CPU usage patterns
### 7.2 Benchmark Framework
```python
# Proposed performance test structure
class TestGatewayPerformance:
@pytest.mark.benchmark
def test_message_throughput(self, benchmark):
# Measure messages processed per second
pass
@pytest.mark.benchmark
def test_session_creation_latency(self, benchmark):
# Measure session setup time
pass
```
### 7.3 Performance Regression Detection
1. **Baseline Establishment**
- Record baseline metrics for critical paths
- Store in version control
2. **Automated Comparison**
- Compare PR performance against baseline
- Fail if degradation > 10%
3. **Metrics to Track**
- Test suite execution time
- Memory peak usage
- Individual test durations
---
## 8. Test Infrastructure Improvements
### 8.1 Coverage Tooling
**Missing:** Code coverage reporting
**Recommendation:** Add `pytest-cov` to dev dependencies
```toml
[project.optional-dependencies]
dev = [
"pytest>=9.0.2,<10",
"pytest-asyncio>=1.3.0,<2",
"pytest-xdist>=3.0,<4",
"pytest-cov>=5.0,<6", # Add this
"mcp>=1.2.0,<2"
]
```
### 8.2 Test Categories
Add more pytest markers for selective test running:
```python
# In pytest.ini or pyproject.toml
markers = [
"integration: marks tests requiring external services",
"slow: marks slow tests (>5s)",
"security: marks security-focused tests",
"benchmark: marks performance benchmark tests",
"flakey: marks tests that may be unstable",
]
```
### 8.3 Test Data Factory
Create centralized test data factories:
```python
# tests/factories.py
class AgentFactory:
@staticmethod
def create_mock_agent(tools=None):
# Return configured mock agent
pass
class MessageFactory:
@staticmethod
def create_user_message(content):
# Return formatted user message
pass
```
---
## 9. Summary & Action Items
### Immediate Actions (High Impact)
1. **Add coverage reporting** to CI pipeline
2. **Create tests for uncovered security-critical modules:**
- `tools/code_execution_tool.py`
- `tools/browser_tool.py`
- `tools/terminal_tool.py`
3. **Split oversized test files** for better maintainability
4. **Add Gemini adapter tests** (increasingly important provider)
### Short-term (1-2 Sprints)
5. Create integration tests for cross-component flows
6. Add performance benchmarks for critical paths
7. Expand OpenRouter client test coverage
8. Add knowledge ingester tests
### Long-term (Quarter)
9. Achieve 80% code coverage across all modules
10. Implement performance regression testing
11. Create comprehensive security test suite
12. Document testing patterns and best practices
---
## Appendix: Test File Size Distribution
| Lines | Count | Category |
|-------|-------|----------|
| 0-100 | ~50 | Simple unit tests |
| 100-500 | ~200 | Standard test files |
| 500-1000 | ~80 | Complex feature tests |
| 1000-2000 | ~30 | Large test suites |
| 2000+ | ~13 | Monolithic test files (needs splitting) |
---
*Analysis generated: March 30, 2026*
*Total test files analyzed: 373*
*Estimated test functions: ~4,311*

View File

@@ -1,364 +0,0 @@
# Test Optimization Guide for Hermes Agent
## Current Test Execution Analysis
### Test Suite Statistics
- **Total Test Files:** 373
- **Estimated Test Functions:** ~4,311
- **Async Tests:** ~679 (15.8%)
- **Integration Tests:** 7 files (excluded from CI)
- **Average Tests per File:** ~11.6
### Current CI Configuration
```yaml
# .github/workflows/tests.yml
- name: Run tests
run: |
source .venv/bin/activate
python -m pytest tests/ -q --ignore=tests/integration --tb=short -n auto
```
**Current Flags:**
- `-q`: Quiet mode
- `--ignore=tests/integration`: Skip integration tests
- `--tb=short`: Short traceback format
- `-n auto`: Auto-detect parallel workers
---
## Optimization Recommendations
### 1. Add Test Duration Reporting
**Current:** No duration tracking
**Recommended:**
```yaml
run: |
python -m pytest tests/ \
--ignore=tests/integration \
-n auto \
--durations=20 \ # Show 20 slowest tests
--durations-min=1.0 # Only show tests >1s
```
This will help identify slow tests that need optimization.
### 2. Implement Test Categories
Add markers to `pyproject.toml`:
```toml
[tool.pytest.ini_options]
testpaths = ["tests"]
markers = [
"integration: marks tests requiring external services",
"slow: marks tests that take >5 seconds",
"unit: marks fast unit tests",
"security: marks security-focused tests",
"flakey: marks tests that may be unstable",
]
addopts = "-m 'not integration and not slow' -n auto"
```
**Usage:**
```bash
# Run only fast unit tests
pytest -m unit
# Run all tests including slow ones
pytest -m "not integration"
# Run only security tests
pytest -m security
```
### 3. Optimize Slow Test Candidates
Based on file sizes, these tests likely need optimization:
| File | Lines | Optimization Strategy |
|------|-------|----------------------|
| `test_run_agent.py` | 3,329 | Split into multiple files by feature |
| `test_mcp_tool.py` | 2,902 | Split by MCP functionality |
| `test_voice_command.py` | 2,632 | Review for redundant tests |
| `test_feishu.py` | 2,580 | Mock external API calls |
| `test_api_server.py` | 1,503 | Parallelize independent tests |
### 4. Add Coverage Reporting to CI
**Updated workflow:**
```yaml
- name: Run tests with coverage
run: |
source .venv/bin/activate
python -m pytest tests/ \
--ignore=tests/integration \
-n auto \
--cov=agent --cov=tools --cov=gateway --cov=hermes_cli \
--cov-report=xml \
--cov-report=html \
--cov-fail-under=70
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
files: ./coverage.xml
fail_ci_if_error: true
```
### 5. Implement Flaky Test Handling
Add `pytest-rerunfailures`:
```toml
dev = [
"pytest>=9.0.2,<10",
"pytest-asyncio>=1.3.0,<2",
"pytest-xdist>=3.0,<4",
"pytest-cov>=5.0,<6",
"pytest-rerunfailures>=14.0,<15", # Add this
]
```
**Usage:**
```python
# Mark known flaky tests
@pytest.mark.flakey(reruns=3, reruns_delay=1)
async def test_network_dependent_feature():
# Test that sometimes fails due to network
pass
```
### 6. Optimize Fixture Scopes
Review `conftest.py` fixtures:
```python
# Current: Function scope (runs for every test)
@pytest.fixture()
def mock_config():
return {...}
# Optimized: Session scope (runs once per session)
@pytest.fixture(scope="session")
def mock_config():
return {...}
# Optimized: Module scope (runs once per module)
@pytest.fixture(scope="module")
def expensive_setup():
# Setup that can be reused across module
pass
```
### 7. Parallel Execution Tuning
**Current:** `-n auto` (uses all CPUs)
**Issues:**
- May cause resource contention
- Some tests may not be thread-safe
**Recommendations:**
```bash
# Limit workers to prevent resource exhaustion
pytest -n 4 # Use 4 workers regardless of CPU count
# Use load-based scheduling for uneven test durations
pytest -n auto --dist=load
# Group tests by module to reduce setup overhead
pytest -n auto --dist=loadscope
```
### 8. Test Data Management
**Current Issue:** Tests may create files in `/tmp` without cleanup
**Solution - Factory Pattern:**
```python
# tests/factories.py
import tempfile
import shutil
from contextlib import contextmanager
@contextmanager
def temp_workspace():
"""Create isolated temp directory for tests."""
path = tempfile.mkdtemp(prefix="hermes_test_")
try:
yield Path(path)
finally:
shutil.rmtree(path, ignore_errors=True)
# Usage in tests
def test_file_operations():
with temp_workspace() as tmp:
# All file operations in isolated directory
file_path = tmp / "test.txt"
file_path.write_text("content")
assert file_path.exists()
# Automatically cleaned up
```
### 9. Database/State Isolation
**Current:** Uses `monkeypatch` for env vars
**Enhancement:** Database mocking
```python
@pytest.fixture
def mock_honcho():
"""Mock Honcho client for tests."""
with patch("honcho_integration.client.HonchoClient") as mock:
mock_instance = MagicMock()
mock_instance.get_session.return_value = {"id": "test-session"}
mock.return_value = mock_instance
yield mock
# Usage
async def test_memory_storage(mock_honcho):
# Fast, isolated test
pass
```
### 10. CI Pipeline Optimization
**Current Pipeline:**
1. Checkout
2. Install uv
3. Install Python
4. Install deps
5. Run tests
**Optimized Pipeline (with caching):**
```yaml
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v5
with:
version: "0.5.x"
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
cache: 'pip' # Cache pip dependencies
- name: Cache uv packages
uses: actions/cache@v4
with:
path: ~/.cache/uv
key: ${{ runner.os }}-uv-${{ hashFiles('**/pyproject.toml') }}
- name: Install dependencies
run: |
uv venv .venv
uv pip install -e ".[all,dev]"
- name: Run fast tests
run: |
source .venv/bin/activate
pytest -m "not integration and not slow" -n auto --tb=short
- name: Run slow tests
if: github.event_name == 'pull_request'
run: |
source .venv/bin/activate
pytest -m "slow" -n 2 --tb=short
```
---
## Quick Wins (Implement First)
### 1. Add Duration Reporting (5 minutes)
```yaml
--durations=10
```
### 2. Mark Slow Tests (30 minutes)
Add `@pytest.mark.slow` to tests taking >5s.
### 3. Split Largest Test File (2 hours)
Split `test_run_agent.py` into:
- `test_run_agent_core.py`
- `test_run_agent_tools.py`
- `test_run_agent_memory.py`
- `test_run_agent_messaging.py`
### 4. Add Coverage Baseline (1 hour)
```bash
pytest --cov=agent --cov=tools --cov=gateway tests/ --cov-report=html
```
### 5. Optimize Fixture Scopes (1 hour)
Review and optimize 5 most-used fixtures.
---
## Long-term Improvements
### Test Data Generation
```python
# Implement hypothesis-based testing
from hypothesis import given, strategies as st
@given(st.lists(st.text(), min_size=1))
def test_message_batching(messages):
# Property-based testing
pass
```
### Performance Regression Testing
```python
@pytest.mark.benchmark
def test_message_processing_speed(benchmark):
result = benchmark(process_messages, sample_data)
assert result.throughput > 1000 # msgs/sec
```
### Contract Testing
```python
# Verify API contracts between components
@pytest.mark.contract
def test_agent_tool_contract():
"""Verify agent sends correct format to tools."""
pass
```
---
## Measurement Checklist
After implementing optimizations, verify:
- [ ] Test suite execution time < 5 minutes
- [ ] No individual test > 10 seconds (except integration)
- [ ] Code coverage > 70%
- [ ] All flaky tests marked and retried
- [ ] CI passes consistently (>95% success rate)
- [ ] Memory usage stable (no leaks in test suite)
---
## Tools to Add
```toml
[project.optional-dependencies]
dev = [
"pytest>=9.0.2,<10",
"pytest-asyncio>=1.3.0,<2",
"pytest-xdist>=3.0,<4",
"pytest-cov>=5.0,<6",
"pytest-rerunfailures>=14.0,<15",
"pytest-benchmark>=4.0,<5", # Performance testing
"pytest-mock>=3.12,<4", # Enhanced mocking
"hypothesis>=6.100,<7", # Property-based testing
"factory-boy>=3.3,<4", # Test data factories
]
```

View File

@@ -1,73 +0,0 @@
# V-006 MCP OAuth Deserialization Vulnerability Fix
## Summary
Fixed the critical V-006 vulnerability (CVSS 8.8) in MCP OAuth handling that used insecure deserialization, potentially enabling remote code execution.
## Changes Made
### 1. Secure OAuth State Serialization (`tools/mcp_oauth.py`)
- **Replaced pickle with JSON**: OAuth state is now serialized using JSON instead of `pickle.loads()`, eliminating the RCE vector
- **Added HMAC-SHA256 signatures**: All state data is cryptographically signed to prevent tampering
- **Implemented secure deserialization**: `SecureOAuthState.deserialize()` validates structure, signature, and expiration
- **Added constant-time comparison**: Token validation uses `secrets.compare_digest()` to prevent timing attacks
### 2. Token Storage Security Enhancements
- **JSON Schema Validation**: Token data is validated against strict schemas before use
- **HMAC Signing**: Stored tokens are signed with HMAC-SHA256 to detect file tampering
- **Strict Type Checking**: All token fields are type-validated
- **File Permissions**: Token directory created with 0o700, files with 0o600
### 3. Security Features
- **Nonce-based replay protection**: Each state has a unique nonce tracked by the state manager
- **10-minute expiration**: States automatically expire after 600 seconds
- **CSRF protection**: State validation prevents cross-site request forgery
- **Environment-based keys**: Supports `HERMES_OAUTH_SECRET` and `HERMES_TOKEN_STORAGE_SECRET` env vars
### 4. Comprehensive Security Tests (`tests/test_oauth_state_security.py`)
54 security tests covering:
- Serialization/deserialization roundtrips
- Tampering detection (data and signature)
- Schema validation for tokens and client info
- Replay attack prevention
- CSRF attack prevention
- MITM attack detection
- Pickle payload rejection
- Performance tests
## Files Modified
- `tools/mcp_oauth.py` - Complete rewrite with secure state handling
- `tests/test_oauth_state_security.py` - New comprehensive security test suite
## Security Verification
```bash
# Run security tests
python tests/test_oauth_state_security.py
# All 54 tests pass:
# - TestSecureOAuthState: 20 tests
# - TestOAuthStateManager: 10 tests
# - TestSchemaValidation: 8 tests
# - TestTokenStorageSecurity: 6 tests
# - TestNoPickleUsage: 2 tests
# - TestSecretKeyManagement: 3 tests
# - TestOAuthFlowIntegration: 3 tests
# - TestPerformance: 2 tests
```
## API Changes (Backwards Compatible)
- `SecureOAuthState` - New class for secure state handling
- `OAuthStateManager` - New class for state lifecycle management
- `HermesTokenStorage` - Enhanced with schema validation and signing
- `OAuthStateError` - New exception for security violations
## Deployment Notes
1. Existing token files will be invalidated (no signature) - users will need to re-authenticate
2. New secret key will be auto-generated in `~/.hermes/.secrets/`
3. Environment variables can override key locations:
- `HERMES_OAUTH_SECRET` - For state signing
- `HERMES_TOKEN_STORAGE_SECRET` - For token storage signing
## References
- Security Audit: V-006 Insecure Deserialization in MCP OAuth
- CWE-502: Deserialization of Untrusted Data
- CWE-20: Improper Input Validation

View File

@@ -0,0 +1,172 @@
# Vector Database SOTA Research Report
## For AI Agent Semantic Retrieval — April 2026
---
## Executive Summary
Analysis of current vector database benchmarks, documentation, and production deployments for semantic retrieval in AI agents. Compared against existing Hermes session_search (SQLite FTS5) and holographic memory systems.
---
## 1. Retrieval Accuracy (Recall@10)
| Database | HNSW Recall | IVF Recall | Notes |
|----------|-------------|------------|-------|
| **Qdrant** | 0.95-0.99 | N/A | Tunable via ef parameter |
| **Milvus** | 0.95-0.99 | 0.85-0.95 | Multiple index support |
| **Weaviate** | 0.95-0.98 | N/A | HNSW primary |
| **Pinecone** | 0.95-0.99 | N/A | Managed, opaque tuning |
| **ChromaDB** | 0.90-0.95 | N/A | Simpler, uses HNSW via hnswlib |
| **pgvector** | 0.85-0.95 | 0.80-0.90 | Depends on tuning |
| **SQLite-vss** | 0.80-0.90 | N/A | HNSW via sqlite-vss |
| **Current FTS5** | ~0.60-0.75* | N/A | Keyword matching only |
*FTS5 "recall" estimated: good for exact keywords, poor for semantic/paraphrased queries.
---
## 2. Latency Benchmarks (1M vectors, 768-dim, 10 neighbors)
| Database | p50 (ms) | p99 (ms) | QPS | Notes |
|----------|----------|----------|-----|-------|
| **Qdrant** | 1-3 | 5-10 | 5,000-15,000 | Best self-hosted |
| **Milvus** | 2-5 | 8-15 | 3,000-12,000 | Good distributed |
| **Weaviate** | 3-8 | 10-25 | 2,000-8,000 | |
| **Pinecone** | 5-15 | 20-50 | 1,000-5,000 | Managed overhead |
| **ChromaDB** | 5-15 | 20-50 | 500-2,000 | Embedded mode |
| **pgvector** | 10-50 | 50-200 | 200-1,000 | SQL overhead |
| **SQLite-vss** | 10-30 | 50-150 | 300-800 | Limited scalability |
| **Current FTS5** | 2-10 | 15-50 | 1,000-5,000 | No embedding cost |
---
## 3. Index Types Comparison
### HNSW (Hierarchical Navigable Small World)
- Best for: High recall, moderate memory, fast queries
- Used by: Qdrant, Weaviate, ChromaDB, Milvus, pgvector, SQLite-vss
- Memory: High (~1.5GB per 1M 768-dim vectors)
- Key parameters: ef_construction (100-500), M (16-64), ef (64-256)
### IVF (Inverted File Index)
- Best for: Large datasets, memory-constrained
- Used by: Milvus, pgvector
- Memory: Lower (~0.5GB per 1M vectors)
- Key parameters: nlist (100-10000), nprobe (10-100)
### DiskANN / SPANN
- Best for: 100M+ vectors on disk
- Memory: Very low (~100MB index)
### Quantization (SQ/PQ)
- Memory reduction: 4-8x
- Recall impact: -5-15%
---
## 4. Multi-Modal Support
| Database | Text | Image | Audio | Video | Mixed Queries |
|----------|------|-------|-------|-------|---------------|
| Qdrant | ✅ | ✅ | ✅ | ✅ | ✅ (multi-vector) |
| Milvus | ✅ | ✅ | ✅ | ✅ | ✅ (hybrid) |
| Weaviate | ✅ | ✅ | ✅ | ✅ | ✅ (named vectors) |
| Pinecone | ✅ | ✅ | ✅ | ✅ | Limited |
| ChromaDB | ✅ | Via emb | Via emb | Via emb | Limited |
| pgvector | ✅ | Via emb | Via emb | Via emb | Limited |
| SQLite-vss | ✅ | Via emb | Via emb | Via emb | Limited |
---
## 5. Integration Patterns for AI Agents
### Pattern A: Direct Search
Query → Embedding → Vector DB → Top-K → LLM
### Pattern B: Hybrid Search
Query → BM25 + Vector → Merge/Rerank → LLM
### Pattern C: Multi-Stage
Query → Vector DB (top-100) → Reranker (top-10) → LLM
### Pattern D: Agent Memory with Trust + Decay
Query → Vector → Score × Trust × Decay → Top-K → Summarize
---
## 6. Comparison with Current Systems
### session_search (FTS5)
Strengths: Zero deps, no embedding needed, fast for exact keywords
Limitations: No semantic understanding, no cross-lingual, limited ranking
### holographic/retrieval.py (HRR)
Strengths: Compositional queries, contradiction detection, trust + decay
Limitations: Requires numpy, O(n) scan, non-standard embedding space
### Expected Gains from Vector DB:
- Semantic recall: +30-50% for paraphrased queries
- Cross-lingual: +60-80%
- Fuzzy matching: +40-60%
- Conceptual: +50-70%
---
## 7. Recommendations
### Option 1: Qdrant (RECOMMENDED)
- Best self-hosted performance
- Rust implementation, native multi-vector
- Tradeoff: Separate service deployment
### Option 2: pgvector (CONSERVATIVE)
- Zero new infrastructure if using PostgreSQL
- Tradeoff: 5-10x slower than Qdrant
### Option 3: SQLite-vss (LIGHTWEIGHT)
- Minimal changes, embedded deployment
- Tradeoff: Limited scalability (<100K vectors)
### Option 4: Hybrid (BEST OF BOTH)
Keep FTS5 + HRR and add Qdrant:
- Vector (semantic) + FTS5 (keyword) + HRR (compositional)
- Apply trust scoring + temporal decay
---
## 8. Embedding Models (2025-2026)
| Model | Dimensions | Quality | Cost |
|-------|-----------|---------|------|
| OpenAI text-embedding-3-large | 3072 | Best | $$$ |
| OpenAI text-embedding-3-small | 1536 | Good | $ |
| BGE-M3 | 1024 | Best self-hosted | Free |
| GTE-Qwen2 | 768-1024 | Good | Free |
---
## 9. Hardware Requirements (1M vectors, 768-dim)
| Database | RAM (HNSW) | RAM (Quantized) |
|----------|-----------|-----------------|
| Qdrant | 8-16GB | 2-4GB |
| Milvus | 16-32GB | 4-8GB |
| pgvector | 4-8GB | N/A |
| SQLite-vss | 2-4GB | N/A |
---
## 10. Conclusion
Primary: Qdrant with hybrid search (vector + FTS5 + HRR)
Key insight: Augment existing HRR system, don't replace it.
Next steps:
1. Deploy Qdrant in Docker for testing
2. Benchmark embedding models
3. Implement hybrid search prototype
4. Measure recall improvement
5. Evaluate operational complexity
Report: April 2026 | Sources: ANN-Benchmarks, VectorDBBench, official docs

View File

@@ -15,7 +15,6 @@ Usage::
import asyncio
import logging
import os
import sys
from pathlib import Path
from hermes_constants import get_hermes_home

View File

@@ -36,6 +36,7 @@ from acp.schema import (
SessionCapabilities,
SessionForkCapabilities,
SessionListCapabilities,
SessionResumeCapabilities,
SessionInfo,
TextContentBlock,
UnstructuredCommandInput,
@@ -245,9 +246,11 @@ class HermesACPAgent(acp.Agent):
protocol_version=acp.PROTOCOL_VERSION,
agent_info=Implementation(name="hermes-agent", version=HERMES_VERSION),
agent_capabilities=AgentCapabilities(
load_session=True,
session_capabilities=SessionCapabilities(
fork=SessionForkCapabilities(),
list=SessionListCapabilities(),
resume=SessionResumeCapabilities(),
),
),
auth_methods=auth_methods,
@@ -451,14 +454,13 @@ class HermesACPAgent(acp.Agent):
await conn.session_update(session_id, update)
usage = None
usage_data = result.get("usage")
if usage_data and isinstance(usage_data, dict):
if any(result.get(key) is not None for key in ("prompt_tokens", "completion_tokens", "total_tokens")):
usage = Usage(
input_tokens=usage_data.get("prompt_tokens", 0),
output_tokens=usage_data.get("completion_tokens", 0),
total_tokens=usage_data.get("total_tokens", 0),
thought_tokens=usage_data.get("reasoning_tokens"),
cached_read_tokens=usage_data.get("cached_tokens"),
input_tokens=result.get("prompt_tokens", 0),
output_tokens=result.get("completion_tokens", 0),
total_tokens=result.get("total_tokens", 0),
thought_tokens=result.get("reasoning_tokens"),
cached_read_tokens=result.get("cache_read_tokens"),
)
stop_reason = "cancelled" if state.cancel_event and state.cancel_event.is_set() else "end_turn"

View File

@@ -262,8 +262,6 @@ class SessionManager:
if self._db_instance is not None:
return self._db_instance
try:
import os
from pathlib import Path
from hermes_state import SessionDB
hermes_home = get_hermes_home()
self._db_instance = SessionDB(db_path=hermes_home / "state.db")

View File

@@ -4,22 +4,3 @@ These modules contain pure utility functions and self-contained classes
that were previously embedded in the 3,600-line run_agent.py. Extracting
them makes run_agent.py focused on the AIAgent orchestrator class.
"""
# Import input sanitizer for convenient access
from agent.input_sanitizer import (
detect_jailbreak_patterns,
sanitize_input,
sanitize_input_full,
score_input_risk,
should_block_input,
RiskLevel,
)
__all__ = [
"detect_jailbreak_patterns",
"sanitize_input",
"sanitize_input_full",
"score_input_risk",
"should_block_input",
"RiskLevel",
]

443
agent/a2a_mtls.py Normal file
View File

@@ -0,0 +1,443 @@
"""
A2A mutual-TLS server — secure agent-to-agent communication.
Each fleet agent runs an A2A server that:
- Presents its own TLS certificate (signed by the fleet CA).
- Requires the connecting peer to present a valid client certificate
also signed by the fleet CA.
- Rejects connections from unknown / self-signed peers.
Usage (standalone):
python -m agent.a2a_mtls \\
--cert ~/.hermes/pki/agents/timmy/timmy.crt \\
--key ~/.hermes/pki/agents/timmy/timmy.key \\
--ca ~/.hermes/pki/ca/fleet-ca.crt \\
--host 0.0.0.0 --port 9443
Environment variables (alternative to CLI flags):
HERMES_A2A_CERT path to agent certificate
HERMES_A2A_KEY path to agent private key
HERMES_A2A_CA path to fleet CA certificate
Refs #806
"""
from __future__ import annotations
import json
import logging
import os
import ssl
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
from typing import Any, Callable, Dict, Optional
from urllib.error import URLError
from urllib.request import Request, urlopen
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# mTLS SSL context helpers
# ---------------------------------------------------------------------------
def build_server_ssl_context(
cert: str | Path,
key: str | Path,
ca: str | Path,
) -> ssl.SSLContext:
"""Return an SSLContext that presents *cert/key* and requires a valid
client certificate signed by *ca*.
Raises ``FileNotFoundError`` if any path is missing.
Raises ``ssl.SSLError`` if the files are malformed.
"""
cert, key, ca = Path(cert), Path(key), Path(ca)
for p in (cert, key, ca):
if not p.exists():
raise FileNotFoundError(f"mTLS: file not found: {p}")
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
ctx.load_cert_chain(certfile=str(cert), keyfile=str(key))
ctx.load_verify_locations(cafile=str(ca))
# CERT_REQUIRED — reject peers that don't present a cert signed by *ca*.
ctx.verify_mode = ssl.CERT_REQUIRED
return ctx
def build_client_ssl_context(
cert: str | Path,
key: str | Path,
ca: str | Path,
) -> ssl.SSLContext:
"""Return an SSLContext for an outgoing mTLS connection.
Presents *cert/key* as the client identity and verifies the server
certificate against *ca*.
"""
cert, key, ca = Path(cert), Path(key), Path(ca)
for p in (cert, key, ca):
if not p.exists():
raise FileNotFoundError(f"mTLS client: file not found: {p}")
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
ctx.load_cert_chain(certfile=str(cert), keyfile=str(key))
ctx.load_verify_locations(cafile=str(ca))
ctx.verify_mode = ssl.CERT_REQUIRED
ctx.check_hostname = True
return ctx
# ---------------------------------------------------------------------------
# Minimal A2A HTTP request handler
# ---------------------------------------------------------------------------
class A2AHandler(BaseHTTPRequestHandler):
"""Handles A2A requests over a mutually-authenticated TLS connection.
GET /.well-known/agent-card.json — returns the local agent card.
POST /a2a/task — dispatches an A2A task (stub).
"""
log_message = logger.debug # route access log to Python logger
def do_GET(self) -> None: # noqa: N802
if self.path in ("/.well-known/agent-card.json", "/agent-card.json"):
self._serve_agent_card()
else:
self._send_json(404, {"error": "not found"})
def do_POST(self) -> None: # noqa: N802
if self.path == "/a2a/task":
self._handle_task()
else:
self._send_json(404, {"error": "not found"})
# ------------------------------------------------------------------
def _serve_agent_card(self) -> None:
try:
from agent.agent_card import get_agent_card_json
body = get_agent_card_json().encode()
except Exception as exc:
logger.warning("agent-card unavailable: %s", exc)
body = b'{"error": "agent card unavailable"}'
self._send_raw(200, "application/json", body)
def _handle_task(self) -> None:
length = int(self.headers.get("Content-Length", 0))
_body = self.rfile.read(length) if length else b""
# Stub: echo back a 202 Accepted with the peer CN so callers can
# confirm which agent processed the request.
peer_cn = _peer_cn(self.connection)
self._send_json(202, {"status": "accepted", "handled_by": peer_cn})
# ------------------------------------------------------------------
def _send_json(self, code: int, data: dict) -> None:
import json
body = json.dumps(data).encode()
self._send_raw(code, "application/json", body)
def _send_raw(self, code: int, content_type: str, body: bytes) -> None:
self.send_response(code)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, fmt: str, *args: object) -> None: # type: ignore[override]
logger.debug("a2a: " + fmt, *args)
def _peer_cn(conn: ssl.SSLSocket) -> Optional[str]:
"""Extract the Common Name from the peer certificate, or None."""
try:
peer = conn.getpeercert()
if not peer:
return None
for rdn in peer.get("subject", ()):
for key, val in rdn:
if key == "commonName":
return val
except Exception:
pass
return None
# ---------------------------------------------------------------------------
# Server lifecycle
# ---------------------------------------------------------------------------
class A2AServer:
"""Mutual-TLS A2A server.
Example::
server = A2AServer(
cert="~/.hermes/pki/agents/timmy/timmy.crt",
key="~/.hermes/pki/agents/timmy/timmy.key",
ca="~/.hermes/pki/ca/fleet-ca.crt",
)
server.start() # non-blocking (daemon thread)
...
server.stop()
"""
def __init__(
self,
cert: str | Path,
key: str | Path,
ca: str | Path,
host: str = "0.0.0.0",
port: int = 9443,
) -> None:
self.cert = Path(cert).expanduser()
self.key = Path(key).expanduser()
self.ca = Path(ca).expanduser()
self.host = host
self.port = port
self._httpd: Optional[HTTPServer] = None
self._thread: Optional[threading.Thread] = None
def start(self, daemon: bool = True) -> None:
"""Start the server in a background thread (default: daemon)."""
ssl_ctx = build_server_ssl_context(self.cert, self.key, self.ca)
self._httpd = HTTPServer((self.host, self.port), A2AHandler)
self._httpd.socket = ssl_ctx.wrap_socket(
self._httpd.socket, server_side=True
)
self._thread = threading.Thread(
target=self._httpd.serve_forever, daemon=daemon
)
self._thread.start()
logger.info(
"A2A mTLS server listening on %s:%s (cert=%s)",
self.host, self.port, self.cert.name,
)
def stop(self) -> None:
if self._httpd:
self._httpd.shutdown()
self._httpd = None
if self._thread:
self._thread.join(timeout=5)
self._thread = None
def server_from_env() -> A2AServer:
"""Build an A2AServer from environment variables / defaults."""
hermes_home = Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes"))
agent_name = os.environ.get("HERMES_AGENT_NAME", "hermes").lower()
default_cert = hermes_home / "pki" / "agents" / agent_name / f"{agent_name}.crt"
default_key = hermes_home / "pki" / "agents" / agent_name / f"{agent_name}.key"
default_ca = hermes_home / "pki" / "ca" / "fleet-ca.crt"
cert = os.environ.get("HERMES_A2A_CERT", str(default_cert))
key = os.environ.get("HERMES_A2A_KEY", str(default_key))
ca = os.environ.get("HERMES_A2A_CA", str(default_ca))
host = os.environ.get("HERMES_A2A_HOST", "0.0.0.0")
port = int(os.environ.get("HERMES_A2A_PORT", "9443"))
return A2AServer(cert=cert, key=key, ca=ca, host=host, port=port)
# ---------------------------------------------------------------------------
# CLI entry point
# ---------------------------------------------------------------------------
def _main() -> None:
import argparse
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
parser = argparse.ArgumentParser(
description="Hermes A2A mutual-TLS server"
)
parser.add_argument("--cert", required=True, help="Path to agent certificate")
parser.add_argument("--key", required=True, help="Path to agent private key")
parser.add_argument("--ca", required=True, help="Path to fleet CA certificate")
parser.add_argument("--host", default="0.0.0.0")
parser.add_argument("--port", type=int, default=9443)
args = parser.parse_args()
server = A2AServer(
cert=args.cert, key=args.key, ca=args.ca,
host=args.host, port=args.port,
)
server.start(daemon=False)
if __name__ == "__main__":
_main()
# ---------------------------------------------------------------------------
# A2AMTLSServer — routing-based server with context-manager support
# ---------------------------------------------------------------------------
class _RoutingHandler(BaseHTTPRequestHandler):
"""HTTP request handler that dispatches to per-path callables."""
routes: Dict[str, Callable] = {}
def log_message(self, fmt: str, *args: Any) -> None:
logger.debug("A2AMTLSServer: " + fmt, *args)
def _peer_cn(self) -> Optional[str]:
cert = self.connection.getpeercert() # type: ignore[attr-defined]
if not cert:
return None
for rdn in cert.get("subject", ()):
for attr, value in rdn:
if attr == "commonName":
return value
return None
def do_POST(self) -> None:
handler = self.routes.get(self.path)
if handler is None:
self.send_response(404)
self.end_headers()
return
length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(length) if length else b""
try:
payload = json.loads(body) if body else {}
except json.JSONDecodeError:
self.send_response(400)
self.end_headers()
return
result = handler(payload, peer_cn=self._peer_cn())
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps(result).encode())
def do_GET(self) -> None:
handler = self.routes.get(self.path)
if handler is None:
self.send_response(404)
self.end_headers()
return
result = handler({}, peer_cn=self._peer_cn())
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps(result).encode())
class A2AMTLSServer:
"""Routing-based mTLS HTTPS server with context-manager support.
Unlike ``A2AServer`` (which serves fixed A2A paths), this server lets
callers register arbitrary path handlers — useful for tests and custom
A2A endpoint implementations.
handler signature: ``handler(payload: dict, *, peer_cn: str | None) -> dict``
Example::
server = A2AMTLSServer(cert="timmy.crt", key="timmy.key", ca="fleet-ca.crt")
server.add_route("/tasks/send", my_handler)
with server:
... # server runs for the duration of the block
"""
def __init__(
self,
cert: str | Path,
key: str | Path,
ca: str | Path,
host: str = "127.0.0.1",
port: int = 9443,
) -> None:
self.cert = Path(cert).expanduser()
self.key = Path(key).expanduser()
self.ca = Path(ca).expanduser()
self.host = host
self.port = port
self._routes: Dict[str, Callable] = {}
self._httpd: Optional[HTTPServer] = None
self._thread: Optional[threading.Thread] = None
def add_route(self, path: str, handler: Callable) -> None:
self._routes[path] = handler
def start(self) -> None:
ssl_ctx = build_server_ssl_context(self.cert, self.key, self.ca)
class _Handler(_RoutingHandler):
routes = self._routes
self._httpd = HTTPServer((self.host, self.port), _Handler)
self._httpd.socket = ssl_ctx.wrap_socket(self._httpd.socket, server_side=True)
self._thread = threading.Thread(
target=self._httpd.serve_forever,
daemon=True,
name=f"a2a-mtls-{self.port}",
)
self._thread.start()
logger.info("A2AMTLSServer on %s:%d (mTLS)", self.host, self.port)
def stop(self) -> None:
if self._httpd:
self._httpd.shutdown()
self._httpd = None
if self._thread:
self._thread.join(timeout=5)
self._thread = None
def __enter__(self) -> "A2AMTLSServer":
self.start()
return self
def __exit__(self, *_: Any) -> None:
self.stop()
# ---------------------------------------------------------------------------
# A2AMTLSClient — mTLS HTTP client
# ---------------------------------------------------------------------------
class A2AMTLSClient:
"""HTTP client that presents a fleet cert on every outgoing connection.
Example::
client = A2AMTLSClient(cert="allegro.crt", key="allegro.key", ca="fleet-ca.crt")
result = client.post("https://timmy:9443/tasks/send", json={"task": "..."})
"""
def __init__(
self,
cert: str | Path,
key: str | Path,
ca: str | Path,
) -> None:
self._ssl_ctx = build_client_ssl_context(cert, key, ca)
self._ssl_ctx.check_hostname = False # callers connecting by IP
def _request(
self,
method: str,
url: str,
data: Optional[bytes] = None,
timeout: float = 10.0,
) -> Dict[str, Any]:
headers = {"Content-Type": "application/json"}
req = Request(url, data=data, headers=headers, method=method)
try:
with urlopen(req, context=self._ssl_ctx, timeout=timeout) as resp:
body = resp.read()
return json.loads(body) if body else {}
except URLError as exc:
raise ConnectionError(f"A2AMTLSClient {method} {url} failed: {exc.reason}") from exc
def get(self, url: str, **kwargs: Any) -> Dict[str, Any]:
return self._request("GET", url, **kwargs)
def post(self, url: str, json: Optional[Dict[str, Any]] = None, **kwargs: Any) -> Dict[str, Any]:
data = (__import__("json").dumps(json).encode() if json is not None else None)
return self._request("POST", url, data=data, **kwargs)

135
agent/agent_card.py Normal file
View File

@@ -0,0 +1,135 @@
"""
Agent Card — A2A-compliant agent discovery.
Part of #843: fix: implement A2A agent card for fleet discovery (#819)
Provides metadata about the agent's identity, capabilities, and installed skills
for discovery by other agents in the fleet.
"""
import json
import logging
import os
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional
from hermes_cli import __version__
from hermes_cli.config import load_config, get_hermes_home
from agent.skill_utils import (
iter_skill_index_files,
parse_frontmatter,
get_all_skills_dirs,
get_disabled_skill_names,
skill_matches_platform
)
logger = logging.getLogger(__name__)
@dataclass
class AgentSkill:
id: str
name: str
description: str = ""
version: str = "1.0.0"
@dataclass
class AgentCapabilities:
streaming: bool = True
tools: bool = True
vision: bool = False
reasoning: bool = False
@dataclass
class AgentCard:
name: str
description: str
url: str
version: str = __version__
capabilities: AgentCapabilities = field(default_factory=AgentCapabilities)
skills: List[AgentSkill] = field(default_factory=list)
defaultInputModes: List[str] = field(default_factory=lambda: ["text/plain"])
defaultOutputModes: List[str] = field(default_factory=lambda: ["text/plain"])
def _load_skills() -> List[AgentSkill]:
"""Scan all enabled skills and return metadata."""
skills = []
disabled = get_disabled_skill_names()
for skills_dir in get_all_skills_dirs():
if not skills_dir.is_dir():
continue
for skill_file in iter_skill_index_files(skills_dir, "SKILL.md"):
try:
raw = skill_file.read_text(encoding="utf-8")
frontmatter, _ = parse_frontmatter(raw)
except Exception:
continue
skill_name = frontmatter.get("name") or skill_file.parent.name
if str(skill_name) in disabled:
continue
if not skill_matches_platform(frontmatter):
continue
skills.append(AgentSkill(
id=str(skill_name),
name=str(frontmatter.get("name", skill_name)),
description=str(frontmatter.get("description", "")),
version=str(frontmatter.get("version", "1.0.0"))
))
return skills
def build_agent_card() -> AgentCard:
"""Build the agent card from current configuration and environment."""
config = load_config()
# Identity
name = os.environ.get("HERMES_AGENT_NAME") or config.get("agent", {}).get("name") or "hermes"
description = os.environ.get("HERMES_AGENT_DESCRIPTION") or config.get("agent", {}).get("description") or "Sovereign AI agent"
# URL - try to determine from environment or config
port = os.environ.get("HERMES_WEB_PORT") or "9119"
host = os.environ.get("HERMES_WEB_HOST") or "localhost"
url = f"http://{host}:{port}"
# Capabilities
# In a real scenario, we'd check model metadata for vision/reasoning
capabilities = AgentCapabilities(
streaming=True,
tools=True,
vision=False, # Default to false unless we can confirm
reasoning=False
)
# Skills
skills = _load_skills()
return AgentCard(
name=name,
description=description,
url=url,
version=__version__,
capabilities=capabilities,
skills=skills
)
def get_agent_card_json() -> str:
"""Return the agent card as a JSON string."""
try:
card = build_agent_card()
return json.dumps(asdict(card), indent=2)
except Exception as e:
logger.error(f"Failed to build agent card: {e}")
# Minimal fallback card
fallback = {
"name": "hermes",
"description": "Sovereign AI agent (fallback)",
"version": __version__,
"error": str(e)
}
return json.dumps(fallback, indent=2)
def validate_agent_card(card_data: Dict[str, Any]) -> bool:
"""Check if the card data complies with the A2A schema."""
required = ["name", "description", "url", "version"]
return all(k in card_data for k in required)

View File

@@ -60,6 +60,8 @@ _ANTHROPIC_OUTPUT_LIMITS = {
"claude-3-opus": 4_096,
"claude-3-sonnet": 4_096,
"claude-3-haiku": 4_096,
# Third-party Anthropic-compatible providers
"minimax": 131_072,
}
# For any model not in the table, assume the highest current limit.
@@ -74,8 +76,11 @@ def _get_anthropic_max_output(model: str) -> int:
model IDs (claude-sonnet-4-5-20250929) and variant suffixes (:1m, :fast)
resolve correctly. Longest-prefix match wins to avoid e.g. "claude-3-5"
matching before "claude-3-5-sonnet".
Normalizes dots to hyphens so that model names like
``anthropic/claude-opus-4.6`` match the ``claude-opus-4-6`` table key.
"""
m = model.lower()
m = model.lower().replace(".", "-")
best_key = ""
best_val = _ANTHROPIC_DEFAULT_OUTPUT_LIMIT
for key, val in _ANTHROPIC_OUTPUT_LIMITS.items():
@@ -95,6 +100,15 @@ _COMMON_BETAS = [
"interleaved-thinking-2025-05-14",
"fine-grained-tool-streaming-2025-05-14",
]
# MiniMax's Anthropic-compatible endpoints fail tool-use requests when
# the fine-grained tool streaming beta is present. Omit it so tool calls
# fall back to the provider's default response path.
_TOOL_STREAMING_BETA = "fine-grained-tool-streaming-2025-05-14"
# Fast mode beta — enables the ``speed: "fast"`` request parameter for
# significantly higher output token throughput on Opus 4.6 (~2.5x).
# See https://platform.claude.com/docs/en/build-with-claude/fast-mode
_FAST_MODE_BETA = "fast-mode-2026-02-01"
# Additional beta headers required for OAuth/subscription auth.
# Matches what Claude Code (and pi-ai / OpenCode) send.
@@ -149,18 +163,38 @@ def _get_claude_code_version() -> str:
def _is_oauth_token(key: str) -> bool:
"""Check if the key is an OAuth/setup token (not a regular Console API key).
"""Check if the key is an Anthropic OAuth/setup token.
Regular API keys start with 'sk-ant-api'. Everything else (setup-tokens
starting with 'sk-ant-oat', managed keys, JWTs, etc.) needs Bearer auth.
Positively identifies Anthropic OAuth tokens by their key format:
- ``sk-ant-`` prefix (but NOT ``sk-ant-api``) → setup tokens, managed keys
- ``eyJ`` prefix → JWTs from the Anthropic OAuth flow
Non-Anthropic keys (MiniMax, Alibaba, etc.) don't match either pattern
and correctly return False.
"""
if not key:
return False
# Regular Console API keys use x-api-key header
# Regular Anthropic Console API keys x-api-key auth, never OAuth
if key.startswith("sk-ant-api"):
return False
# Everything else (setup-tokens, managed keys, JWTs) uses Bearer auth
return True
# Anthropic-issued tokens (setup-tokens sk-ant-oat-*, managed keys)
if key.startswith("sk-ant-"):
return True
# JWTs from Anthropic OAuth flow
if key.startswith("eyJ"):
return True
return False
def _normalize_base_url_text(base_url) -> str:
"""Normalize SDK/base transport URL values to a plain string for inspection.
Some client objects expose ``base_url`` as an ``httpx.URL`` instead of a raw
string. Provider/auth detection should accept either shape.
"""
if not base_url:
return ""
return str(base_url).strip()
def _is_third_party_anthropic_endpoint(base_url: str | None) -> bool:
@@ -170,9 +204,10 @@ def _is_third_party_anthropic_endpoint(base_url: str | None) -> bool:
with their own API keys via x-api-key, not Anthropic OAuth tokens. OAuth
detection should be skipped for these endpoints.
"""
if not base_url:
normalized = _normalize_base_url_text(base_url)
if not normalized:
return False # No base_url = direct Anthropic API
normalized = base_url.rstrip("/").lower()
normalized = normalized.rstrip("/").lower()
if "anthropic.com" in normalized:
return False # Direct Anthropic API — OAuth applies
return True # Any other endpoint is a third-party proxy
@@ -182,15 +217,27 @@ def _requires_bearer_auth(base_url: str | None) -> bool:
"""Return True for Anthropic-compatible providers that require Bearer auth.
Some third-party /anthropic endpoints implement Anthropic's Messages API but
require Authorization: Bearer instead of Anthropic's native x-api-key header.
require Authorization: Bearer *** of Anthropic's native x-api-key header.
MiniMax's global and China Anthropic-compatible endpoints follow this pattern.
"""
if not base_url:
normalized = _normalize_base_url_text(base_url)
if not normalized:
return False
normalized = base_url.rstrip("/").lower()
return normalized.startswith("https://api.minimax.io/anthropic") or normalized.startswith(
"https://api.minimaxi.com/anthropic"
)
normalized = normalized.rstrip("/").lower()
return normalized.startswith(("https://api.minimax.io/anthropic", "https://api.minimaxi.com/anthropic"))
def _common_betas_for_base_url(base_url: str | None) -> list[str]:
"""Return the beta headers that are safe for the configured endpoint.
MiniMax's Anthropic-compatible endpoints (Bearer-auth) reject requests
that include Anthropic's ``fine-grained-tool-streaming`` beta — every
tool-use message triggers a connection error. Strip that beta for
Bearer-auth endpoints while keeping all other betas intact.
"""
if _requires_bearer_auth(base_url):
return [b for b in _COMMON_BETAS if b != _TOOL_STREAMING_BETA]
return _COMMON_BETAS
def build_anthropic_client(api_key: str, base_url: str = None):
@@ -205,13 +252,15 @@ def build_anthropic_client(api_key: str, base_url: str = None):
)
from httpx import Timeout
normalized_base_url = _normalize_base_url_text(base_url)
kwargs = {
"timeout": Timeout(timeout=900.0, connect=10.0),
}
if base_url:
kwargs["base_url"] = base_url
if normalized_base_url:
kwargs["base_url"] = normalized_base_url
common_betas = _common_betas_for_base_url(normalized_base_url)
if _requires_bearer_auth(base_url):
if _requires_bearer_auth(normalized_base_url):
# Some Anthropic-compatible providers (e.g. MiniMax) expect the API key in
# Authorization: Bearer even for regular API keys. Route those endpoints
# through auth_token so the SDK sends Bearer auth instead of x-api-key.
@@ -219,21 +268,21 @@ def build_anthropic_client(api_key: str, base_url: str = None):
# not use Anthropic's sk-ant-api prefix and would otherwise be misread as
# Anthropic OAuth/setup tokens.
kwargs["auth_token"] = api_key
if _COMMON_BETAS:
kwargs["default_headers"] = {"anthropic-beta": ",".join(_COMMON_BETAS)}
if common_betas:
kwargs["default_headers"] = {"anthropic-beta": ",".join(common_betas)}
elif _is_third_party_anthropic_endpoint(base_url):
# Third-party proxies (Azure AI Foundry, AWS Bedrock, etc.) use their
# own API keys with x-api-key auth. Skip OAuth detection — their keys
# don't follow Anthropic's sk-ant-* prefix convention and would be
# misclassified as OAuth tokens.
kwargs["api_key"] = api_key
if _COMMON_BETAS:
kwargs["default_headers"] = {"anthropic-beta": ",".join(_COMMON_BETAS)}
if common_betas:
kwargs["default_headers"] = {"anthropic-beta": ",".join(common_betas)}
elif _is_oauth_token(api_key):
# OAuth access token / setup-token → Bearer auth + Claude Code identity.
# Anthropic routes OAuth requests based on user-agent and headers;
# without Claude Code's fingerprint, requests get intermittent 500s.
all_betas = _COMMON_BETAS + _OAUTH_ONLY_BETAS
all_betas = common_betas + _OAUTH_ONLY_BETAS
kwargs["auth_token"] = api_key
kwargs["default_headers"] = {
"anthropic-beta": ",".join(all_betas),
@@ -243,8 +292,8 @@ def build_anthropic_client(api_key: str, base_url: str = None):
else:
# Regular API key → x-api-key header + common betas
kwargs["api_key"] = api_key
if _COMMON_BETAS:
kwargs["default_headers"] = {"anthropic-beta": ",".join(_COMMON_BETAS)}
if common_betas:
kwargs["default_headers"] = {"anthropic-beta": ",".join(common_betas)}
return _anthropic_sdk.Anthropic(**kwargs)
@@ -473,35 +522,6 @@ def _prefer_refreshable_claude_code_token(env_token: str, creds: Optional[Dict[s
return None
def get_anthropic_token_source(token: Optional[str] = None) -> str:
"""Best-effort source classification for an Anthropic credential token."""
token = (token or "").strip()
if not token:
return "none"
env_token = os.getenv("ANTHROPIC_TOKEN", "").strip()
if env_token and env_token == token:
return "anthropic_token_env"
cc_env_token = os.getenv("CLAUDE_CODE_OAUTH_TOKEN", "").strip()
if cc_env_token and cc_env_token == token:
return "claude_code_oauth_token_env"
creds = read_claude_code_credentials()
if creds and creds.get("accessToken") == token:
return str(creds.get("source") or "claude_code_credentials")
managed_key = read_claude_managed_key()
if managed_key and managed_key == token:
return "claude_json_primary_api_key"
api_key = os.getenv("ANTHROPIC_API_KEY", "").strip()
if api_key and api_key == token:
return "anthropic_api_key_env"
return "unknown"
def resolve_anthropic_token() -> Optional[str]:
"""Resolve an Anthropic token from all available sources.
@@ -708,44 +728,6 @@ def run_hermes_oauth_login_pure() -> Optional[Dict[str, Any]]:
}
def run_hermes_oauth_login() -> Optional[str]:
"""Run Hermes-native OAuth PKCE flow for Claude Pro/Max subscription.
Opens a browser to claude.ai for authorization, prompts for the code,
exchanges it for tokens, and stores them in ~/.hermes/.anthropic_oauth.json.
Returns the access token on success, None on failure.
"""
result = run_hermes_oauth_login_pure()
if not result:
return None
access_token = result["access_token"]
refresh_token = result["refresh_token"]
expires_at_ms = result["expires_at_ms"]
_save_hermes_oauth_credentials(access_token, refresh_token, expires_at_ms)
_write_claude_code_credentials(access_token, refresh_token, expires_at_ms)
print("Authentication successful!")
return access_token
def _save_hermes_oauth_credentials(access_token: str, refresh_token: str, expires_at_ms: int) -> None:
"""Save OAuth credentials to ~/.hermes/.anthropic_oauth.json."""
data = {
"accessToken": access_token,
"refreshToken": refresh_token,
"expiresAt": expires_at_ms,
}
try:
_HERMES_OAUTH_FILE.parent.mkdir(parents=True, exist_ok=True)
_HERMES_OAUTH_FILE.write_text(json.dumps(data, indent=2), encoding="utf-8")
_HERMES_OAUTH_FILE.chmod(0o600)
except (OSError, IOError) as e:
logger.debug("Failed to save Hermes OAuth credentials: %s", e)
def read_hermes_oauth_credentials() -> Optional[Dict[str, Any]]:
"""Read Hermes-managed OAuth credentials from ~/.hermes/.anthropic_oauth.json."""
if _HERMES_OAUTH_FILE.exists():
@@ -758,38 +740,6 @@ def read_hermes_oauth_credentials() -> Optional[Dict[str, Any]]:
return None
def refresh_hermes_oauth_token() -> Optional[str]:
"""Refresh the Hermes-managed OAuth token using the stored refresh token.
Returns the new access token, or None if refresh fails.
"""
creds = read_hermes_oauth_credentials()
if not creds or not creds.get("refreshToken"):
return None
try:
refreshed = refresh_anthropic_oauth_pure(
creds["refreshToken"],
use_json=True,
)
_save_hermes_oauth_credentials(
refreshed["access_token"],
refreshed["refresh_token"],
refreshed["expires_at_ms"],
)
_write_claude_code_credentials(
refreshed["access_token"],
refreshed["refresh_token"],
refreshed["expires_at_ms"],
)
logger.debug("Successfully refreshed Hermes OAuth token")
return refreshed["access_token"]
except Exception as e:
logger.debug("Failed to refresh Hermes OAuth token: %s", e)
return None
# ---------------------------------------------------------------------------
# Message / tool / response format conversion
# ---------------------------------------------------------------------------
@@ -826,68 +776,6 @@ def _sanitize_tool_id(tool_id: str) -> str:
return sanitized or "tool_0"
def _convert_openai_image_part_to_anthropic(part: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Convert an OpenAI-style image block to Anthropic's image source format."""
image_data = part.get("image_url", {})
url = image_data.get("url", "") if isinstance(image_data, dict) else str(image_data)
if not isinstance(url, str) or not url.strip():
return None
url = url.strip()
if url.startswith("data:"):
header, sep, data = url.partition(",")
if sep and ";base64" in header:
media_type = header[5:].split(";", 1)[0] or "image/png"
return {
"type": "image",
"source": {
"type": "base64",
"media_type": media_type,
"data": data,
},
}
if url.startswith("http://") or url.startswith("https://"):
return {
"type": "image",
"source": {
"type": "url",
"url": url,
},
}
return None
def _convert_user_content_part_to_anthropic(part: Any) -> Optional[Dict[str, Any]]:
if isinstance(part, dict):
ptype = part.get("type")
if ptype == "text":
block = {"type": "text", "text": part.get("text", "")}
if isinstance(part.get("cache_control"), dict):
block["cache_control"] = dict(part["cache_control"])
return block
if ptype == "image_url":
return _convert_openai_image_part_to_anthropic(part)
if ptype == "image" and part.get("source"):
return dict(part)
if ptype == "image" and part.get("data"):
media_type = part.get("mimeType") or part.get("media_type") or "image/png"
return {
"type": "image",
"source": {
"type": "base64",
"media_type": media_type,
"data": part.get("data", ""),
},
}
if ptype == "tool_result":
return dict(part)
elif part is not None:
return {"type": "text", "text": str(part)}
return None
def convert_tools_to_anthropic(tools: List[Dict]) -> List[Dict]:
"""Convert OpenAI tool definitions to Anthropic format."""
if not tools:
@@ -1028,12 +916,18 @@ def _convert_content_to_anthropic(content: Any) -> Any:
def convert_messages_to_anthropic(
messages: List[Dict],
base_url: str | None = None,
) -> Tuple[Optional[Any], List[Dict]]:
"""Convert OpenAI-format messages to Anthropic format.
Returns (system_prompt, anthropic_messages).
System messages are extracted since Anthropic takes them as a separate param.
system_prompt is a string or list of content blocks (when cache_control present).
When *base_url* is provided and points to a third-party Anthropic-compatible
endpoint, all thinking block signatures are stripped. Signatures are
Anthropic-proprietary — third-party endpoints cannot validate them and will
reject them with HTTP 400 "Invalid signature in thinking block".
"""
system = None
result = []
@@ -1188,7 +1082,15 @@ def convert_messages_to_anthropic(
curr_content = [{"type": "text", "text": curr_content}]
fixed[-1]["content"] = prev_content + curr_content
else:
# Consecutive assistant messages — merge text content
# Consecutive assistant messages — merge text content.
# Drop thinking blocks from the *second* message: their
# signature was computed against a different turn boundary
# and becomes invalid once merged.
if isinstance(m["content"], list):
m["content"] = [
b for b in m["content"]
if not (isinstance(b, dict) and b.get("type") in ("thinking", "redacted_thinking"))
]
prev_blocks = fixed[-1]["content"]
curr_blocks = m["content"]
if isinstance(prev_blocks, list) and isinstance(curr_blocks, list):
@@ -1206,6 +1108,79 @@ def convert_messages_to_anthropic(
fixed.append(m)
result = fixed
# ── Thinking block signature management ──────────────────────────
# Anthropic signs thinking blocks against the full turn content.
# Any upstream mutation (context compression, session truncation,
# orphan stripping, message merging) invalidates the signature,
# causing HTTP 400 "Invalid signature in thinking block".
#
# Signatures are Anthropic-proprietary. Third-party endpoints
# (MiniMax, Azure AI Foundry, self-hosted proxies) cannot validate
# them and will reject them outright. When targeting a third-party
# endpoint, strip ALL thinking/redacted_thinking blocks from every
# assistant message — the third-party will generate its own
# thinking blocks if it supports extended thinking.
#
# For direct Anthropic (strategy following clawdbot/OpenClaw):
# 1. Strip thinking/redacted_thinking from all assistant messages
# EXCEPT the last one — preserves reasoning continuity on the
# current tool-use chain while avoiding stale signature errors.
# 2. Downgrade unsigned thinking blocks (no signature) to text —
# Anthropic can't validate them and will reject them.
# 3. Strip cache_control from thinking/redacted_thinking blocks —
# cache markers can interfere with signature validation.
_THINKING_TYPES = frozenset(("thinking", "redacted_thinking"))
_is_third_party = _is_third_party_anthropic_endpoint(base_url)
last_assistant_idx = None
for i in range(len(result) - 1, -1, -1):
if result[i].get("role") == "assistant":
last_assistant_idx = i
break
for idx, m in enumerate(result):
if m.get("role") != "assistant" or not isinstance(m.get("content"), list):
continue
if _is_third_party or idx != last_assistant_idx:
# Third-party endpoint: strip ALL thinking blocks from every
# assistant message — signatures are Anthropic-proprietary.
# Direct Anthropic: strip from non-latest assistant messages only.
stripped = [
b for b in m["content"]
if not (isinstance(b, dict) and b.get("type") in _THINKING_TYPES)
]
m["content"] = stripped or [{"type": "text", "text": "(thinking elided)"}]
else:
# Latest assistant on direct Anthropic: keep signed thinking
# blocks for reasoning continuity; downgrade unsigned ones to
# plain text.
new_content = []
for b in m["content"]:
if not isinstance(b, dict) or b.get("type") not in _THINKING_TYPES:
new_content.append(b)
continue
if b.get("type") == "redacted_thinking":
# Redacted blocks use 'data' for the signature payload
if b.get("data"):
new_content.append(b)
# else: drop — no data means it can't be validated
elif b.get("signature"):
# Signed thinking block — keep it
new_content.append(b)
else:
# Unsigned thinking — downgrade to text so it's not lost
thinking_text = b.get("thinking", "")
if thinking_text:
new_content.append({"type": "text", "text": thinking_text})
m["content"] = new_content or [{"type": "text", "text": "(empty)"}]
# Strip cache_control from any remaining thinking/redacted_thinking
# blocks — cache markers interfere with signature validation.
for b in m["content"]:
if isinstance(b, dict) and b.get("type") in _THINKING_TYPES:
b.pop("cache_control", None)
return system, result
@@ -1219,28 +1194,59 @@ def build_anthropic_kwargs(
is_oauth: bool = False,
preserve_dots: bool = False,
context_length: Optional[int] = None,
base_url: str | None = None,
fast_mode: bool = False,
) -> Dict[str, Any]:
"""Build kwargs for anthropic.messages.create().
When *max_tokens* is None, the model's native output limit is used
(e.g. 128K for Opus 4.6, 64K for Sonnet 4.6). If *context_length*
is provided, the effective limit is clamped so it doesn't exceed
the context window.
Naming note — two distinct concepts, easily confused:
max_tokens = OUTPUT token cap for a single response.
Anthropic's API calls this "max_tokens" but it only
limits the *output*. Anthropic's own native SDK
renamed it "max_output_tokens" for clarity.
context_length = TOTAL context window (input tokens + output tokens).
The API enforces: input_tokens + max_tokens ≤ context_length.
Stored on the ContextCompressor; reduced on overflow errors.
When *max_tokens* is None the model's native output ceiling is used
(e.g. 128K for Opus 4.6, 64K for Sonnet 4.6).
When *context_length* is provided and the model's native output ceiling
exceeds it (e.g. a local endpoint with an 8K window), the output cap is
clamped to context_length 1. This only kicks in for unusually small
context windows; for full-size models the native output cap is always
smaller than the context window so no clamping happens.
NOTE: this clamping does not account for prompt size — if the prompt is
large, Anthropic may still reject the request. The caller must detect
"max_tokens too large given prompt" errors and retry with a smaller cap
(see parse_available_output_tokens_from_error + _ephemeral_max_output_tokens).
When *is_oauth* is True, applies Claude Code compatibility transforms:
system prompt prefix, tool name prefixing, and prompt sanitization.
When *preserve_dots* is True, model name dots are not converted to hyphens
(for Alibaba/DashScope anthropic-compatible endpoints: qwen3.5-plus).
When *base_url* points to a third-party Anthropic-compatible endpoint,
thinking block signatures are stripped (they are Anthropic-proprietary).
When *fast_mode* is True, adds ``extra_body["speed"] = "fast"`` and the
fast-mode beta header for ~2.5x faster output throughput on Opus 4.6.
Currently only supported on native Anthropic endpoints (not third-party
compatible ones).
"""
system, anthropic_messages = convert_messages_to_anthropic(messages)
system, anthropic_messages = convert_messages_to_anthropic(messages, base_url=base_url)
anthropic_tools = convert_tools_to_anthropic(tools) if tools else []
model = normalize_model_name(model, preserve_dots=preserve_dots)
# effective_max_tokens = output cap for this call (≠ total context window)
effective_max_tokens = max_tokens or _get_anthropic_max_output(model)
# Clamp to context window if the user set a lower context_length
# (e.g. custom endpoint with limited capacity).
# Clamp output cap to fit inside the total context window.
# Only matters for small custom endpoints where context_length < native
# output ceiling. For standard Anthropic models context_length (e.g.
# 200K) is always larger than the output ceiling (e.g. 128K), so this
# branch is not taken.
if context_length and effective_max_tokens > context_length:
effective_max_tokens = max(context_length - 1, 1)
@@ -1310,7 +1316,8 @@ def build_anthropic_kwargs(
# Map reasoning_config to Anthropic's thinking parameter.
# Claude 4.6 models use adaptive thinking + output_config.effort.
# Older models use manual thinking with budget_tokens.
# Haiku models do NOT support extended thinking at all — skip entirely.
# MiniMax Anthropic-compat endpoints support thinking (manual mode only,
# not adaptive). Haiku does NOT support extended thinking — skip entirely.
if reasoning_config and isinstance(reasoning_config, dict):
if reasoning_config.get("enabled") is not False and "haiku" not in model.lower():
effort = str(reasoning_config.get("effort", "medium")).lower()
@@ -1326,6 +1333,20 @@ def build_anthropic_kwargs(
kwargs["temperature"] = 1
kwargs["max_tokens"] = max(effective_max_tokens, budget + 4096)
# ── Fast mode (Opus 4.6 only) ────────────────────────────────────
# Adds extra_body.speed="fast" + the fast-mode beta header for ~2.5x
# output speed. Only for native Anthropic endpoints — third-party
# providers would reject the unknown beta header and speed parameter.
if fast_mode and not _is_third_party_anthropic_endpoint(base_url):
kwargs.setdefault("extra_body", {})["speed"] = "fast"
# Build extra_headers with ALL applicable betas (the per-request
# extra_headers override the client-level anthropic-beta header).
betas = list(_common_betas_for_base_url(base_url))
if is_oauth:
betas.extend(_OAUTH_ONLY_BETAS)
betas.append(_FAST_MODE_BETA)
kwargs["extra_headers"] = {"anthropic-beta": ",".join(betas)}
return kwargs
@@ -1387,4 +1408,4 @@ def normalize_anthropic_response(
reasoning_details=reasoning_details or None,
),
finish_reason,
)
)

File diff suppressed because it is too large Load Diff

View File

@@ -1,113 +0,0 @@
"""BuiltinMemoryProvider — wraps MEMORY.md / USER.md as a MemoryProvider.
Always registered as the first provider. Cannot be disabled or removed.
This is the existing Hermes memory system exposed through the provider
interface for compatibility with the MemoryManager.
The actual storage logic lives in tools/memory_tool.py (MemoryStore).
This provider is a thin adapter that delegates to MemoryStore and
exposes the memory tool schema.
"""
from __future__ import annotations
import json
import logging
from typing import Any, Dict, List, Optional
from agent.memory_provider import MemoryProvider
logger = logging.getLogger(__name__)
class BuiltinMemoryProvider(MemoryProvider):
"""Built-in file-backed memory (MEMORY.md + USER.md).
Always active, never disabled by other providers. The `memory` tool
is handled by run_agent.py's agent-level tool interception (not through
the normal registry), so get_tool_schemas() returns an empty list —
the memory tool is already wired separately.
"""
def __init__(
self,
memory_store=None,
memory_enabled: bool = False,
user_profile_enabled: bool = False,
):
self._store = memory_store
self._memory_enabled = memory_enabled
self._user_profile_enabled = user_profile_enabled
@property
def name(self) -> str:
return "builtin"
def is_available(self) -> bool:
"""Built-in memory is always available."""
return True
def initialize(self, session_id: str, **kwargs) -> None:
"""Load memory from disk if not already loaded."""
if self._store is not None:
self._store.load_from_disk()
def system_prompt_block(self) -> str:
"""Return MEMORY.md and USER.md content for the system prompt.
Uses the frozen snapshot captured at load time. This ensures the
system prompt stays stable throughout a session (preserving the
prompt cache), even though the live entries may change via tool calls.
"""
if not self._store:
return ""
parts = []
if self._memory_enabled:
mem_block = self._store.format_for_system_prompt("memory")
if mem_block:
parts.append(mem_block)
if self._user_profile_enabled:
user_block = self._store.format_for_system_prompt("user")
if user_block:
parts.append(user_block)
return "\n\n".join(parts)
def prefetch(self, query: str, *, session_id: str = "") -> str:
"""Built-in memory doesn't do query-based recall — it's injected via system_prompt_block."""
return ""
def sync_turn(self, user_content: str, assistant_content: str, *, session_id: str = "") -> None:
"""Built-in memory doesn't auto-sync turns — writes happen via the memory tool."""
def get_tool_schemas(self) -> List[Dict[str, Any]]:
"""Return empty list.
The `memory` tool is an agent-level intercepted tool, handled
specially in run_agent.py before normal tool dispatch. It's not
part of the standard tool registry. We don't duplicate it here.
"""
return []
def handle_tool_call(self, tool_name: str, args: Dict[str, Any], **kwargs) -> str:
"""Not used — the memory tool is intercepted in run_agent.py."""
return json.dumps({"error": "Built-in memory tool is handled by the agent loop"})
def shutdown(self) -> None:
"""No cleanup needed — files are saved on every write."""
# -- Property access for backward compatibility --------------------------
@property
def store(self):
"""Access the underlying MemoryStore for legacy code paths."""
return self._store
@property
def memory_enabled(self) -> bool:
return self._memory_enabled
@property
def user_profile_enabled(self) -> bool:
return self._user_profile_enabled

273
agent/circuit_breaker.py Normal file
View File

@@ -0,0 +1,273 @@
"""
Circuit Breaker for Error Cascading — #885
P(error | prev was error) = 58.6% vs P(error | prev was success) = 25.2%.
That's a 2.33x cascade factor. After 3 consecutive errors, the circuit
opens and the agent must take corrective action.
States:
- CLOSED: Normal operation, errors are counted
- OPEN: Too many consecutive errors, corrective action required
- HALF_OPEN: Testing if errors have cleared
Usage:
from agent.circuit_breaker import CircuitBreaker, ToolCircuitBreaker
cb = ToolCircuitBreaker()
# After each tool call
if not cb.record_result(success=True):
# Circuit is open — take corrective action
cb.get_recovery_action()
"""
import time
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Dict, List, Optional
class CircuitState(Enum):
CLOSED = "closed" # Normal operation
OPEN = "open" # Too many errors, block execution
HALF_OPEN = "half_open" # Testing recovery
@dataclass
class CircuitBreaker:
"""
Generic circuit breaker with configurable thresholds.
Tracks consecutive errors and opens the circuit when the
error streak exceeds the threshold.
"""
failure_threshold: int = 3
recovery_timeout: float = 30.0 # seconds before trying half-open
success_threshold: int = 2 # successes needed to close from half-open
state: CircuitState = field(default=CircuitState.CLOSED, init=False)
consecutive_failures: int = field(default=0, init=False)
consecutive_successes: int = field(default=0, init=False)
last_failure_time: Optional[float] = field(default=None, init=False)
total_trips: int = field(default=0, init=False)
error_streaks: List[int] = field(default_factory=list, init=False)
def record_result(self, success: bool) -> bool:
"""
Record a tool call result. Returns True if circuit allows execution.
Returns:
True if circuit is CLOSED or HALF_OPEN (execution allowed)
False if circuit is OPEN (execution blocked)
"""
now = time.time()
if self.state == CircuitState.OPEN:
# Check if recovery timeout has passed
if self.last_failure_time and (now - self.last_failure_time) >= self.recovery_timeout:
self.state = CircuitState.HALF_OPEN
self.consecutive_successes = 0
return True # Allow one test execution
return False # Still open
if success:
self.consecutive_failures = 0
self.consecutive_successes += 1
if self.state == CircuitState.HALF_OPEN:
if self.consecutive_successes >= self.success_threshold:
self.state = CircuitState.CLOSED
self.consecutive_successes = 0
return True
else:
self.consecutive_successes = 0
self.consecutive_failures += 1
self.last_failure_time = now
if self.state == CircuitState.HALF_OPEN:
# Failed during recovery — reopen immediately
self.state = CircuitState.OPEN
self.total_trips += 1
return False
if self.consecutive_failures >= self.failure_threshold:
self.state = CircuitState.OPEN
self.total_trips += 1
self.error_streaks.append(self.consecutive_failures)
return False
return True
def can_execute(self) -> bool:
"""Check if execution is allowed."""
if self.state == CircuitState.OPEN:
if self.last_failure_time:
now = time.time()
if (now - self.last_failure_time) >= self.recovery_timeout:
self.state = CircuitState.HALF_OPEN
self.consecutive_successes = 0
return True
return False
return True
def get_state(self) -> Dict[str, Any]:
"""Get current circuit state."""
return {
"state": self.state.value,
"consecutive_failures": self.consecutive_failures,
"consecutive_successes": self.consecutive_successes,
"total_trips": self.total_trips,
"max_streak": max(self.error_streaks) if self.error_streaks else 0,
"can_execute": self.can_execute(),
}
def reset(self):
"""Reset the circuit breaker."""
self.state = CircuitState.CLOSED
self.consecutive_failures = 0
self.consecutive_successes = 0
self.last_failure_time = None
class ToolCircuitBreaker(CircuitBreaker):
"""
Circuit breaker specifically for tool call error cascading.
Provides recovery actions when the circuit opens.
"""
# Tools that are most effective at recovery (from audit data)
RECOVERY_TOOLS = [
"terminal", # Most effective — 2300 recoveries
"read_file", # Reset context by reading something
"search_files", # Find what went wrong
]
def get_recovery_action(self) -> Dict[str, Any]:
"""
Get the recommended recovery action when circuit is open.
Returns dict with action type and details.
"""
streak = self.consecutive_failures
if streak >= 9:
# After 9 errors: 41/46 recoveries via terminal
return {
"action": "terminal_only",
"reason": f"Error streak of {streak} — terminal is the only reliable recovery",
"suggested_tool": "terminal",
"suggested_command": "echo 'Resetting context'",
"severity": "critical",
}
elif streak >= 5:
return {
"action": "switch_tool_type",
"reason": f"Error streak of {streak} — switch to a different tool category",
"suggested_tools": ["read_file", "search_files", "terminal"],
"severity": "high",
}
elif streak >= self.failure_threshold:
return {
"action": "ask_user",
"reason": f"{streak} consecutive errors — ask user for guidance",
"suggested_response": "I'm encountering repeated errors. Would you like me to try a different approach?",
"severity": "medium",
}
else:
return {
"action": "continue",
"reason": f"Error streak of {streak} — within tolerance",
"severity": "low",
}
def should_compress_context(self) -> bool:
"""Determine if context compression would help recovery."""
return self.consecutive_failures >= 5
def get_blocked_tool(self) -> Optional[str]:
"""Get the tool that should be blocked (if any)."""
if self.state == CircuitState.OPEN:
return "last_failed_tool"
return None
class MultiToolCircuitBreaker:
"""
Manages per-tool circuit breakers and cross-tool cascade detection.
When one tool trips its breaker, related tools are also warned.
"""
def __init__(self):
self.breakers: Dict[str, ToolCircuitBreaker] = {}
self.global_streak: int = 0
self.last_tool: Optional[str] = None
self.last_success: bool = True
def get_breaker(self, tool_name: str) -> ToolCircuitBreaker:
"""Get or create a circuit breaker for a tool."""
if tool_name not in self.breakers:
self.breakers[tool_name] = ToolCircuitBreaker()
return self.breakers[tool_name]
def record_result(self, tool_name: str, success: bool) -> bool:
"""
Record a tool call result. Returns True if execution should continue.
"""
breaker = self.get_breaker(tool_name)
allowed = breaker.record_result(success)
# Track global streak
if success:
self.global_streak = 0
self.last_success = True
else:
self.global_streak += 1
self.last_success = False
self.last_tool = tool_name
return allowed
def can_execute(self, tool_name: str) -> bool:
"""Check if a specific tool can execute."""
breaker = self.get_breaker(tool_name)
return breaker.can_execute()
def get_global_state(self) -> Dict[str, Any]:
"""Get overall circuit breaker state."""
return {
"global_streak": self.global_streak,
"last_tool": self.last_tool,
"last_success": self.last_success,
"tool_states": {
name: breaker.get_state()
for name, breaker in self.breakers.items()
if breaker.consecutive_failures > 0 or breaker.total_trips > 0
},
"any_open": any(b.state == CircuitState.OPEN for b in self.breakers.values()),
}
def get_recovery_action(self) -> Dict[str, Any]:
"""Get recovery action based on global state."""
if self.global_streak == 0:
return {"action": "continue", "reason": "No errors"}
# Find the breaker with the worst streak
worst = max(self.breakers.values(), key=lambda b: b.consecutive_failures, default=None)
if worst and worst.consecutive_failures > 0:
return worst.get_recovery_action()
return {
"action": "continue",
"reason": f"Global streak: {self.global_streak}",
"severity": "low",
}
def reset_all(self):
"""Reset all circuit breakers."""
for breaker in self.breakers.values():
breaker.reset()
self.global_streak = 0
self.last_success = True

View File

@@ -1,6 +0,0 @@
"""
@soul:honesty.grounding Grounding before generation. Consult verified sources before pattern-matching.
@soul:honesty.source_distinction Source distinction. Every claim must point to a verified source.
@soul:honesty.audit_trail The audit trail. Every response is logged with inputs and confidence.
"""
# This file serves as a registry for the Conscience Validator to prove the apparatus exists.

148
agent/context_budget.py Normal file
View File

@@ -0,0 +1,148 @@
"""
Context Budget Tracker - Prevent context window overflow
Poka-yoke: Visual warnings at 70%%, 85%%, 95%% capacity.
Auto-checkpoint at 85%%. Pre-flight token estimation.
Issue: #838
"""
import json
import logging
import time
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
logger = logging.getLogger(__name__)
HERMES_HOME = Path.home() / ".hermes"
CHECKPOINT_DIR = HERMES_HOME / "checkpoints"
CHARS_PER_TOKEN = 4
THRESHOLD_WARNING = 0.70
THRESHOLD_CRITICAL = 0.85
THRESHOLD_DANGER = 0.95
class ContextBudget:
def __init__(self, context_limit: int = 128000, system_tokens: int = 0,
used_tokens: int = 0, reserved_tokens: int = 2000):
self.context_limit = context_limit
self.system_tokens = system_tokens
self.used_tokens = used_tokens
self.reserved_tokens = reserved_tokens
@property
def total_used(self) -> int:
return self.system_tokens + self.used_tokens
@property
def available(self) -> int:
return max(0, self.context_limit - self.reserved_tokens)
@property
def remaining(self) -> int:
return max(0, self.available - self.total_used)
@property
def utilization(self) -> float:
return self.total_used / self.available if self.available > 0 else 1.0
def estimate_tokens(text: str) -> int:
return len(text) // CHARS_PER_TOKEN if text else 0
def estimate_messages_tokens(messages: List[Dict]) -> int:
total = 0
for msg in messages:
content = msg.get("content", "")
if isinstance(content, str):
total += estimate_tokens(content)
if msg.get("tool_calls"):
total += 100
return total
class ContextBudgetTracker:
def __init__(self, context_limit: int = 128000, session_id: str = ""):
self.budget = ContextBudget(context_limit=context_limit)
self.session_id = session_id
self._checkpointed = False
self._warnings_given = set()
def update_from_messages(self, messages: List[Dict]):
self.budget.used_tokens = estimate_messages_tokens(messages)
def can_fit(self, additional_tokens: int) -> bool:
return self.budget.remaining >= additional_tokens
def preflight_check(self, text: str) -> Tuple[bool, str]:
tokens = estimate_tokens(text)
if not self.can_fit(tokens):
return False, f"Cannot load: ~{tokens:,} tokens needed, {self.budget.remaining:,} remaining"
would_util = (self.budget.total_used + tokens) / self.budget.available if self.budget.available > 0 else 1.0
if would_util >= THRESHOLD_DANGER:
return False, f"Would reach {would_util:.0%%} capacity. Summarize or start new session."
if would_util >= THRESHOLD_CRITICAL:
return True, f"Warning: will reach {would_util:.0%%} capacity."
return True, ""
def get_warning(self) -> Optional[str]:
util = self.budget.utilization
if util >= THRESHOLD_DANGER and "danger" not in self._warnings_given:
self._warnings_given.add("danger")
return f"[CONTEXT CRITICAL: {util:.0%%} used -- {self.budget.remaining:,} tokens left. Summarize or start new session.]"
if util >= THRESHOLD_CRITICAL and "critical" not in self._warnings_given:
self._warnings_given.add("critical")
self._auto_checkpoint()
return f"[CONTEXT WARNING: {util:.0%%} used -- consider summarizing. Auto-checkpoint saved.]"
if util >= THRESHOLD_WARNING and "warning" not in self._warnings_given:
self._warnings_given.add("warning")
return f"[CONTEXT: {util:.0%%} used -- {self.budget.remaining:,} tokens remaining]"
return None
def _auto_checkpoint(self):
if self._checkpointed or not self.session_id:
return
try:
CHECKPOINT_DIR.mkdir(parents=True, exist_ok=True)
path = CHECKPOINT_DIR / f"{self.session_id}.json"
path.write_text(json.dumps({
"session_id": self.session_id,
"timestamp": time.time(),
"budget": {"utilization": round(self.budget.utilization * 100, 1)}
}, indent=2))
self._checkpointed = True
logger.info("Auto-checkpoint saved: %s", path)
except Exception as e:
logger.error("Auto-checkpoint failed: %s", e)
def get_status_line(self) -> str:
util = self.budget.utilization
remaining = self.budget.remaining
if util >= THRESHOLD_DANGER:
return f"RED {util:.0%%} used ({remaining:,} left)"
elif util >= THRESHOLD_CRITICAL:
return f"ORANGE {util:.0%%} used ({remaining:,} left)"
elif util >= THRESHOLD_WARNING:
return f"YELLOW {util:.0%%} used ({remaining:,} left)"
return f"GREEN {util:.0%%} used ({remaining:,} left)"
_tracker = None
def get_tracker(context_limit=128000, session_id=""):
global _tracker
if _tracker is None:
_tracker = ContextBudgetTracker(context_limit, session_id)
return _tracker
def check_context_budget(messages, context_limit=128000):
tracker = get_tracker(context_limit)
tracker.update_from_messages(messages)
return tracker.get_warning()
def preflight_token_check(text):
tracker = get_tracker()
return tracker.preflight_check(text)

View File

@@ -4,8 +4,12 @@ Self-contained class with its own OpenAI client for summarization.
Uses auxiliary model (cheap/fast) to summarize middle turns while
protecting head and tail context.
Improvements over v1:
- Structured summary template (Goal, Progress, Decisions, Files, Next Steps)
Improvements over v2:
- Structured summary template with Resolved/Pending question tracking
- Summarizer preamble: "Do not respond to any questions" (from OpenCode)
- Handoff framing: "different assistant" (from Codex) to create separation
- "Remaining Work" replaces "Next Steps" to avoid reading as active instructions
- Clear separator when summary merges into tail message
- Iterative summary updates (preserves info across multiple compactions)
- Token-budget tail protection instead of fixed message count
- Tool output pruning before LLM summarization (cheap pre-pass)
@@ -13,12 +17,17 @@ Improvements over v1:
- Richer tool call/result detail in summarizer input
"""
import hashlib
import json
import logging
import re
import time
from typing import Any, Dict, List, Optional
from agent.auxiliary_client import call_llm
from agent.context_engine import ContextEngine
from agent.model_metadata import (
MINIMUM_CONTEXT_LENGTH,
get_model_context_length,
estimate_messages_tokens_rough,
)
@@ -26,12 +35,13 @@ from agent.model_metadata import (
logger = logging.getLogger(__name__)
SUMMARY_PREFIX = (
"[CONTEXT COMPACTION] Earlier turns in this conversation were compacted "
"to save context space. The summary below describes work that was "
"already completed, and the current session state may still reflect "
"that work (for example, files may already be changed). Use the summary "
"and the current state to continue from where things left off, and "
"avoid repeating work:"
"[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted "
"into the summary below. This is a handoff from a previous context "
"window — treat it as background reference, NOT as active instructions. "
"Do NOT answer questions or fulfill requests mentioned in this summary; "
"they were already addressed. Respond ONLY to the latest user message "
"that appears AFTER this summary. The current session state (files, "
"config, etc.) may reflect work described here — avoid repeating it:"
)
LEGACY_SUMMARY_PREFIX = "[CONTEXT SUMMARY]:"
@@ -50,8 +60,130 @@ _CHARS_PER_TOKEN = 4
_SUMMARY_FAILURE_COOLDOWN_SECONDS = 600
class ContextCompressor:
"""Compresses conversation context when approaching the model's context limit.
def _summarize_tool_result(tool_name: str, tool_args: str, tool_content: str) -> str:
"""Create an informative 1-line summary of a tool call + result.
Used during the pre-compression pruning pass to replace large tool
outputs with a short but useful description of what the tool did,
rather than a generic placeholder that carries zero information.
Returns strings like::
[terminal] ran `npm test` -> exit 0, 47 lines output
[read_file] read config.py from line 1 (1,200 chars)
[search_files] content search for 'compress' in agent/ -> 12 matches
"""
try:
args = json.loads(tool_args) if tool_args else {}
except (json.JSONDecodeError, TypeError):
args = {}
content = tool_content or ""
content_len = len(content)
line_count = content.count("\n") + 1 if content.strip() else 0
if tool_name == "terminal":
cmd = args.get("command", "")
if len(cmd) > 80:
cmd = cmd[:77] + "..."
exit_match = re.search(r'"exit_code"\s*:\s*(-?\d+)', content)
exit_code = exit_match.group(1) if exit_match else "?"
return f"[terminal] ran `{cmd}` -> exit {exit_code}, {line_count} lines output"
if tool_name == "read_file":
path = args.get("path", "?")
offset = args.get("offset", 1)
return f"[read_file] read {path} from line {offset} ({content_len:,} chars)"
if tool_name == "write_file":
path = args.get("path", "?")
written_lines = args.get("content", "").count("\n") + 1 if args.get("content") else "?"
return f"[write_file] wrote to {path} ({written_lines} lines)"
if tool_name == "search_files":
pattern = args.get("pattern", "?")
path = args.get("path", ".")
target = args.get("target", "content")
match_count = re.search(r'"total_count"\s*:\s*(\d+)', content)
count = match_count.group(1) if match_count else "?"
return f"[search_files] {target} search for '{pattern}' in {path} -> {count} matches"
if tool_name == "patch":
path = args.get("path", "?")
mode = args.get("mode", "replace")
return f"[patch] {mode} in {path} ({content_len:,} chars result)"
if tool_name in ("browser_navigate", "browser_click", "browser_snapshot",
"browser_type", "browser_scroll", "browser_vision"):
url = args.get("url", "")
ref = args.get("ref", "")
detail = f" {url}" if url else (f" ref={ref}" if ref else "")
return f"[{tool_name}]{detail} ({content_len:,} chars)"
if tool_name == "web_search":
query = args.get("query", "?")
return f"[web_search] query='{query}' ({content_len:,} chars result)"
if tool_name == "web_extract":
urls = args.get("urls", [])
url_desc = urls[0] if isinstance(urls, list) and urls else "?"
if isinstance(urls, list) and len(urls) > 1:
url_desc += f" (+{len(urls) - 1} more)"
return f"[web_extract] {url_desc} ({content_len:,} chars)"
if tool_name == "delegate_task":
goal = args.get("goal", "")
if len(goal) > 60:
goal = goal[:57] + "..."
return f"[delegate_task] '{goal}' ({content_len:,} chars result)"
if tool_name == "execute_code":
code_preview = (args.get("code") or "")[:60].replace("\n", " ")
if len(args.get("code", "")) > 60:
code_preview += "..."
return f"[execute_code] `{code_preview}` ({line_count} lines output)"
if tool_name in ("skill_view", "skills_list", "skill_manage"):
name = args.get("name", "?")
return f"[{tool_name}] name={name} ({content_len:,} chars)"
if tool_name == "vision_analyze":
question = args.get("question", "")[:50]
return f"[vision_analyze] '{question}' ({content_len:,} chars)"
if tool_name == "memory":
action = args.get("action", "?")
target = args.get("target", "?")
return f"[memory] {action} on {target}"
if tool_name == "todo":
return "[todo] updated task list"
if tool_name == "clarify":
return "[clarify] asked user a question"
if tool_name == "text_to_speech":
return f"[text_to_speech] generated audio ({content_len:,} chars)"
if tool_name == "cronjob":
action = args.get("action", "?")
return f"[cronjob] {action}"
if tool_name == "process":
action = args.get("action", "?")
sid = args.get("session_id", "?")
return f"[process] {action} session={sid}"
# Generic fallback
first_arg = ""
for k, v in list(args.items())[:2]:
sv = str(v)[:40]
first_arg += f" {k}={sv}"
return f"[{tool_name}]{first_arg} ({content_len:,} chars result)"
class ContextCompressor(ContextEngine):
"""Default context engine — compresses conversation context via lossy summarization.
Algorithm:
1. Prune old tool results (cheap, no LLM call)
@@ -61,6 +193,40 @@ class ContextCompressor:
5. On subsequent compactions, iteratively update the previous summary
"""
@property
def name(self) -> str:
return "compressor"
def on_session_reset(self) -> None:
"""Reset all per-session state for /new or /reset."""
super().on_session_reset()
self._context_probed = False
self._context_probe_persistable = False
self._previous_summary = None
self._last_compression_savings_pct = 100.0
self._ineffective_compression_count = 0
def update_model(
self,
model: str,
context_length: int,
base_url: str = "",
api_key: str = "",
provider: str = "",
api_mode: str = "",
) -> None:
"""Update model info after a model switch or fallback activation."""
self.model = model
self.base_url = base_url
self.api_key = api_key
self.provider = provider
self.api_mode = api_mode
self.context_length = context_length
self.threshold_tokens = max(
int(context_length * self.threshold_percent),
MINIMUM_CONTEXT_LENGTH,
)
def __init__(
self,
model: str,
@@ -74,11 +240,13 @@ class ContextCompressor:
api_key: str = "",
config_context_length: int | None = None,
provider: str = "",
api_mode: str = "",
):
self.model = model
self.base_url = base_url
self.api_key = api_key
self.provider = provider
self.api_mode = api_mode
self.threshold_percent = threshold_percent
self.protect_first_n = protect_first_n
self.protect_last_n = protect_last_n
@@ -90,7 +258,14 @@ class ContextCompressor:
config_context_length=config_context_length,
provider=provider,
)
self.threshold_tokens = int(self.context_length * threshold_percent)
# Floor: never compress below MINIMUM_CONTEXT_LENGTH tokens even if
# the percentage would suggest a lower value. This prevents premature
# compression on large-context models at 50% while keeping the % sane
# for models right at the minimum.
self.threshold_tokens = max(
int(self.context_length * threshold_percent),
MINIMUM_CONTEXT_LENGTH,
)
self.compression_count = 0
# Derive token budgets: ratio is relative to the threshold, not total context
@@ -114,39 +289,42 @@ class ContextCompressor:
self.last_prompt_tokens = 0
self.last_completion_tokens = 0
self.last_total_tokens = 0
self.summary_model = summary_model_override or ""
# Stores the previous compaction summary for iterative updates
self._previous_summary: Optional[str] = None
# Anti-thrashing: track whether last compression was effective
self._last_compression_savings_pct: float = 100.0
self._ineffective_compression_count: int = 0
self._summary_failure_cooldown_until: float = 0.0
def update_from_response(self, usage: Dict[str, Any]):
"""Update tracked token usage from API response."""
self.last_prompt_tokens = usage.get("prompt_tokens", 0)
self.last_completion_tokens = usage.get("completion_tokens", 0)
self.last_total_tokens = usage.get("total_tokens", 0)
def should_compress(self, prompt_tokens: int = None) -> bool:
"""Check if context exceeds the compression threshold."""
"""Check if context exceeds the compression threshold.
Includes anti-thrashing protection: if the last two compressions
each saved less than 10%, skip compression to avoid infinite loops
where each pass removes only 1-2 messages.
"""
tokens = prompt_tokens if prompt_tokens is not None else self.last_prompt_tokens
return tokens >= self.threshold_tokens
def should_compress_preflight(self, messages: List[Dict[str, Any]]) -> bool:
"""Quick pre-flight check using rough estimate (before API call)."""
rough_estimate = estimate_messages_tokens_rough(messages)
return rough_estimate >= self.threshold_tokens
def get_status(self) -> Dict[str, Any]:
"""Get current compression status for display/logging."""
return {
"last_prompt_tokens": self.last_prompt_tokens,
"threshold_tokens": self.threshold_tokens,
"context_length": self.context_length,
"usage_percent": min(100, (self.last_prompt_tokens / self.context_length * 100)) if self.context_length else 0,
"compression_count": self.compression_count,
}
if tokens < self.threshold_tokens:
return False
# Anti-thrashing: back off if recent compressions were ineffective
if self._ineffective_compression_count >= 2:
if not self.quiet_mode:
logger.warning(
"Compression skipped — last %d compressions saved <10%% each. "
"Consider /new to start a fresh session, or /compress <topic> "
"for focused compression.",
self._ineffective_compression_count,
)
return False
return True
# ------------------------------------------------------------------
# Tool output pruning (cheap pre-pass, no LLM call)
@@ -154,12 +332,24 @@ class ContextCompressor:
def _prune_old_tool_results(
self, messages: List[Dict[str, Any]], protect_tail_count: int,
protect_tail_tokens: int | None = None,
) -> tuple[List[Dict[str, Any]], int]:
"""Replace old tool result contents with a short placeholder.
"""Replace old tool result contents with informative 1-line summaries.
Walks backward from the end, protecting the most recent
``protect_tail_count`` messages. Older tool results get their
content replaced with a placeholder string.
Instead of a generic placeholder, generates a summary like::
[terminal] ran `npm test` -> exit 0, 47 lines output
[read_file] read config.py from line 1 (3,400 chars)
Also deduplicates identical tool results (e.g. reading the same file
5x keeps only the newest full copy) and truncates large tool_call
arguments in assistant messages outside the protected tail.
Walks backward from the end, protecting the most recent messages that
fall within ``protect_tail_tokens`` (when provided) OR the last
``protect_tail_count`` messages (backward-compatible default).
When both are given, the token budget takes priority and the message
count acts as a hard minimum floor.
Returns (pruned_messages, pruned_count).
"""
@@ -168,20 +358,110 @@ class ContextCompressor:
result = [m.copy() for m in messages]
pruned = 0
prune_boundary = len(result) - protect_tail_count
# Build index: tool_call_id -> (tool_name, arguments_json)
call_id_to_tool: Dict[str, tuple] = {}
for msg in result:
if msg.get("role") == "assistant":
for tc in msg.get("tool_calls") or []:
if isinstance(tc, dict):
cid = tc.get("id", "")
fn = tc.get("function", {})
call_id_to_tool[cid] = (fn.get("name", "unknown"), fn.get("arguments", ""))
else:
cid = getattr(tc, "id", "") or ""
fn = getattr(tc, "function", None)
name = getattr(fn, "name", "unknown") if fn else "unknown"
args_str = getattr(fn, "arguments", "") if fn else ""
call_id_to_tool[cid] = (name, args_str)
# Determine the prune boundary
if protect_tail_tokens is not None and protect_tail_tokens > 0:
# Token-budget approach: walk backward accumulating tokens
accumulated = 0
boundary = len(result)
min_protect = min(protect_tail_count, len(result) - 1)
for i in range(len(result) - 1, -1, -1):
msg = result[i]
raw_content = msg.get("content") or ""
content_len = sum(len(p.get("text", "")) for p in raw_content) if isinstance(raw_content, list) else len(raw_content)
msg_tokens = content_len // _CHARS_PER_TOKEN + 10
for tc in msg.get("tool_calls") or []:
if isinstance(tc, dict):
args = tc.get("function", {}).get("arguments", "")
msg_tokens += len(args) // _CHARS_PER_TOKEN
if accumulated + msg_tokens > protect_tail_tokens and (len(result) - i) >= min_protect:
boundary = i
break
accumulated += msg_tokens
boundary = i
prune_boundary = max(boundary, len(result) - min_protect)
else:
prune_boundary = len(result) - protect_tail_count
# Pass 1: Deduplicate identical tool results.
# When the same file is read multiple times, keep only the most recent
# full copy and replace older duplicates with a back-reference.
content_hashes: dict = {} # hash -> (index, tool_call_id)
for i in range(len(result) - 1, -1, -1):
msg = result[i]
if msg.get("role") != "tool":
continue
content = msg.get("content") or ""
# Skip multimodal content (list of content blocks)
if isinstance(content, list):
continue
if len(content) < 200:
continue
h = hashlib.md5(content.encode("utf-8", errors="replace")).hexdigest()[:12]
if h in content_hashes:
# This is an older duplicate — replace with back-reference
result[i] = {**msg, "content": "[Duplicate tool output — same content as a more recent call]"}
pruned += 1
else:
content_hashes[h] = (i, msg.get("tool_call_id", "?"))
# Pass 2: Replace old tool results with informative summaries
for i in range(prune_boundary):
msg = result[i]
if msg.get("role") != "tool":
continue
content = msg.get("content", "")
# Skip multimodal content (list of content blocks)
if isinstance(content, list):
continue
if not content or content == _PRUNED_TOOL_PLACEHOLDER:
continue
# Skip already-deduplicated or previously-summarized results
if content.startswith("[Duplicate tool output"):
continue
# Only prune if the content is substantial (>200 chars)
if len(content) > 200:
result[i] = {**msg, "content": _PRUNED_TOOL_PLACEHOLDER}
call_id = msg.get("tool_call_id", "")
tool_name, tool_args = call_id_to_tool.get(call_id, ("unknown", ""))
summary = _summarize_tool_result(tool_name, tool_args, content)
result[i] = {**msg, "content": summary}
pruned += 1
# Pass 3: Truncate large tool_call arguments in assistant messages
# outside the protected tail. write_file with 50KB content, for
# example, survives pruning entirely without this.
for i in range(prune_boundary):
msg = result[i]
if msg.get("role") != "assistant" or not msg.get("tool_calls"):
continue
new_tcs = []
modified = False
for tc in msg["tool_calls"]:
if isinstance(tc, dict):
args = tc.get("function", {}).get("arguments", "")
if len(args) > 500:
tc = {**tc, "function": {**tc["function"], "arguments": args[:200] + "...[truncated]"}}
modified = True
new_tcs.append(tc)
if modified:
result[i] = {**msg, "tool_calls": new_tcs}
return result, pruned
# ------------------------------------------------------------------
@@ -199,30 +479,39 @@ class ContextCompressor:
budget = int(content_tokens * _SUMMARY_RATIO)
return max(_MIN_SUMMARY_TOKENS, min(budget, self.max_summary_tokens))
# Truncation limits for the summarizer input. These bound how much of
# each message the summary model sees — the budget is the *summary*
# model's context window, not the main model's.
_CONTENT_MAX = 6000 # total chars per message body
_CONTENT_HEAD = 4000 # chars kept from the start
_CONTENT_TAIL = 1500 # chars kept from the end
_TOOL_ARGS_MAX = 1500 # tool call argument chars
_TOOL_ARGS_HEAD = 1200 # kept from the start of tool args
def _serialize_for_summary(self, turns: List[Dict[str, Any]]) -> str:
"""Serialize conversation turns into labeled text for the summarizer.
Includes tool call arguments and result content (up to 3000 chars
per message) so the summarizer can preserve specific details like
file paths, commands, and outputs.
Includes tool call arguments and result content (up to
``_CONTENT_MAX`` chars per message) so the summarizer can preserve
specific details like file paths, commands, and outputs.
"""
parts = []
for msg in turns:
role = msg.get("role", "unknown")
content = msg.get("content") or ""
# Tool results: keep more content than before (3000 chars)
# Tool results: keep enough content for the summarizer
if role == "tool":
tool_id = msg.get("tool_call_id", "")
if len(content) > 3000:
content = content[:2000] + "\n...[truncated]...\n" + content[-800:]
if len(content) > self._CONTENT_MAX:
content = content[:self._CONTENT_HEAD] + "\n...[truncated]...\n" + content[-self._CONTENT_TAIL:]
parts.append(f"[TOOL RESULT {tool_id}]: {content}")
continue
# Assistant messages: include tool call names AND arguments
if role == "assistant":
if len(content) > 3000:
content = content[:2000] + "\n...[truncated]...\n" + content[-800:]
if len(content) > self._CONTENT_MAX:
content = content[:self._CONTENT_HEAD] + "\n...[truncated]...\n" + content[-self._CONTENT_TAIL:]
tool_calls = msg.get("tool_calls", [])
if tool_calls:
tc_parts = []
@@ -232,8 +521,8 @@ class ContextCompressor:
name = fn.get("name", "?")
args = fn.get("arguments", "")
# Truncate long arguments but keep enough for context
if len(args) > 500:
args = args[:400] + "..."
if len(args) > self._TOOL_ARGS_MAX:
args = args[:self._TOOL_ARGS_HEAD] + "..."
tc_parts.append(f" {name}({args})")
else:
fn = getattr(tc, "function", None)
@@ -244,19 +533,26 @@ class ContextCompressor:
continue
# User and other roles
if len(content) > 3000:
content = content[:2000] + "\n...[truncated]...\n" + content[-800:]
if len(content) > self._CONTENT_MAX:
content = content[:self._CONTENT_HEAD] + "\n...[truncated]...\n" + content[-self._CONTENT_TAIL:]
parts.append(f"[{role.upper()}]: {content}")
return "\n\n".join(parts)
def _generate_summary(self, turns_to_summarize: List[Dict[str, Any]]) -> Optional[str]:
def _generate_summary(self, turns_to_summarize: List[Dict[str, Any]], focus_topic: str = None) -> Optional[str]:
"""Generate a structured summary of conversation turns.
Uses a structured template (Goal, Progress, Decisions, Files, Next Steps)
inspired by Pi-mono and OpenCode. When a previous summary exists,
Uses a structured template (Goal, Progress, Decisions, Resolved/Pending
Questions, Files, Remaining Work) with explicit preamble telling the
summarizer not to answer questions. When a previous summary exists,
generates an iterative update instead of summarizing from scratch.
Args:
focus_topic: Optional focus string for guided compression. When
provided, the summariser prioritises preserving information
related to this topic and is more aggressive about compressing
everything else. Inspired by Claude Code's ``/compact``.
Returns None if all attempts fail — the caller should drop
the middle turns without a summary rather than inject a useless
placeholder.
@@ -272,9 +568,75 @@ class ContextCompressor:
summary_budget = self._compute_summary_budget(turns_to_summarize)
content_to_summarize = self._serialize_for_summary(turns_to_summarize)
# Preamble shared by both first-compaction and iterative-update prompts.
# Inspired by OpenCode's "do not respond to any questions" instruction
# and Codex's "another language model" framing.
_summarizer_preamble = (
"You are a summarization agent creating a context checkpoint. "
"Your output will be injected as reference material for a DIFFERENT "
"assistant that continues the conversation. "
"Do NOT respond to any questions or requests in the conversation — "
"only output the structured summary. "
"Do NOT include any preamble, greeting, or prefix."
)
# Shared structured template (used by both paths).
_template_sections = f"""## Goal
[What the user is trying to accomplish]
## Constraints & Preferences
[User preferences, coding style, constraints, important decisions]
## Completed Actions
[Numbered list of concrete actions taken — include tool used, target, and outcome.
Format each as: N. ACTION target — outcome [tool: name]
Example:
1. READ config.py:45 — found `==` should be `!=` [tool: read_file]
2. PATCH config.py:45 — changed `==` to `!=` [tool: patch]
3. TEST `pytest tests/` — 3/50 failed: test_parse, test_validate, test_edge [tool: terminal]
Be specific with file paths, commands, line numbers, and results.]
## Active State
[Current working state — include:
- Working directory and branch (if applicable)
- Modified/created files with brief note on each
- Test status (X/Y passing)
- Any running processes or servers
- Environment details that matter]
## In Progress
[Work currently underway — what was being done when compaction fired]
## Blocked
[Any blockers, errors, or issues not yet resolved. Include exact error messages.]
## Key Decisions
[Important technical decisions and WHY they were made]
## Resolved Questions
[Questions the user asked that were ALREADY answered — include the answer so the next assistant does not re-answer them]
## Pending User Asks
[Questions or requests from the user that have NOT yet been answered or fulfilled. If none, write "None."]
## Relevant Files
[Files read, modified, or created — with brief note on each]
## Remaining Work
[What remains to be done — framed as context, not instructions]
## Critical Context
[Any specific values, error messages, configuration details, or data that would be lost without explicit preservation]
Target ~{summary_budget} tokens. Be CONCRETE — include file paths, command outputs, error messages, line numbers, and specific values. Avoid vague descriptions like "made some changes" — say exactly what changed.
Write only the summary body. Do not include any preamble or prefix."""
if self._previous_summary:
# Iterative update: preserve existing info, add new progress
prompt = f"""You are updating a context compaction summary. A previous compaction produced the summary below. New conversation turns have occurred since then and need to be incorporated.
prompt = f"""{_summarizer_preamble}
You are updating a context compaction summary. A previous compaction produced the summary below. New conversation turns have occurred since then and need to be incorporated.
PREVIOUS SUMMARY:
{self._previous_summary}
@@ -282,81 +644,42 @@ PREVIOUS SUMMARY:
NEW TURNS TO INCORPORATE:
{content_to_summarize}
Update the summary using this exact structure. PRESERVE all existing information that is still relevant. ADD new progress. Move items from "In Progress" to "Done" when completed. Remove information only if it is clearly obsolete.
Update the summary using this exact structure. PRESERVE all existing information that is still relevant. ADD new completed actions to the numbered list (continue numbering). Move items from "In Progress" to "Completed Actions" when done. Move answered questions to "Resolved Questions". Update "Active State" to reflect current state. Remove information only if it is clearly obsolete.
## Goal
[What the user is trying to accomplish — preserve from previous summary, update if goal evolved]
## Constraints & Preferences
[User preferences, coding style, constraints, important decisions — accumulate across compactions]
## Progress
### Done
[Completed work — include specific file paths, commands run, results obtained]
### In Progress
[Work currently underway]
### Blocked
[Any blockers or issues encountered]
## Key Decisions
[Important technical decisions and why they were made]
## Relevant Files
[Files read, modified, or created — with brief note on each. Accumulate across compactions.]
## Next Steps
[What needs to happen next to continue the work]
## Critical Context
[Any specific values, error messages, configuration details, or data that would be lost without explicit preservation]
Target ~{summary_budget} tokens. Be specific — include file paths, command outputs, error messages, and concrete values rather than vague descriptions.
Write only the summary body. Do not include any preamble or prefix."""
{_template_sections}"""
else:
# First compaction: summarize from scratch
prompt = f"""Create a structured handoff summary for a later assistant that will continue this conversation after earlier turns are compacted.
prompt = f"""{_summarizer_preamble}
Create a structured handoff summary for a different assistant that will continue this conversation after earlier turns are compacted. The next assistant should be able to understand what happened without re-reading the original turns.
TURNS TO SUMMARIZE:
{content_to_summarize}
Use this exact structure:
## Goal
[What the user is trying to accomplish]
{_template_sections}"""
## Constraints & Preferences
[User preferences, coding style, constraints, important decisions]
# Inject focus topic guidance when the user provides one via /compress <focus>.
# This goes at the end of the prompt so it takes precedence.
if focus_topic:
prompt += f"""
## Progress
### Done
[Completed work — include specific file paths, commands run, results obtained]
### In Progress
[Work currently underway]
### Blocked
[Any blockers or issues encountered]
## Key Decisions
[Important technical decisions and why they were made]
## Relevant Files
[Files read, modified, or created — with brief note on each]
## Next Steps
[What needs to happen next to continue the work]
## Critical Context
[Any specific values, error messages, configuration details, or data that would be lost without explicit preservation]
Target ~{summary_budget} tokens. Be specific — include file paths, command outputs, error messages, and concrete values rather than vague descriptions. The goal is to prevent the next assistant from repeating work or losing important details.
Write only the summary body. Do not include any preamble or prefix."""
FOCUS TOPIC: "{focus_topic}"
The user has requested that this compaction PRIORITISE preserving all information related to the focus topic above. For content related to "{focus_topic}", include full detail — exact values, file paths, command outputs, error messages, and decisions. For content NOT related to the focus topic, summarise more aggressively (brief one-liners or omit if truly irrelevant). The focus topic sections should receive roughly 60-70% of the summary token budget."""
try:
call_kwargs = {
"task": "compression",
"main_runtime": {
"model": self.model,
"provider": self.provider,
"base_url": self.base_url,
"api_key": self.api_key,
"api_mode": self.api_mode,
},
"messages": [{"role": "user", "content": prompt}],
"max_tokens": summary_budget * 2,
"max_tokens": int(summary_budget * 1.3),
# timeout resolved from auxiliary.compression.timeout config by call_llm
}
if self.summary_model:
@@ -370,8 +693,10 @@ Write only the summary body. Do not include any preamble or prefix."""
# Store for iterative updates on next compaction
self._previous_summary = summary
self._summary_failure_cooldown_until = 0.0
self._summary_model_fallen_back = False
return self._with_summary_prefix(summary)
except RuntimeError:
# No provider configured — long cooldown, unlikely to self-resolve
self._summary_failure_cooldown_until = time.monotonic() + _SUMMARY_FAILURE_COOLDOWN_SECONDS
logging.warning("Context compression: no provider available for "
"summary. Middle turns will be dropped without summary "
@@ -379,12 +704,42 @@ Write only the summary body. Do not include any preamble or prefix."""
_SUMMARY_FAILURE_COOLDOWN_SECONDS)
return None
except Exception as e:
self._summary_failure_cooldown_until = time.monotonic() + _SUMMARY_FAILURE_COOLDOWN_SECONDS
# If the summary model is different from the main model and the
# error looks permanent (model not found, 503, 404), fall back to
# using the main model instead of entering cooldown that leaves
# context growing unbounded. (#8620 sub-issue 4)
_status = getattr(e, "status_code", None) or getattr(getattr(e, "response", None), "status_code", None)
_err_str = str(e).lower()
_is_model_not_found = (
_status in (404, 503)
or "model_not_found" in _err_str
or "does not exist" in _err_str
or "no available channel" in _err_str
)
if (
_is_model_not_found
and self.summary_model
and self.summary_model != self.model
and not getattr(self, "_summary_model_fallen_back", False)
):
self._summary_model_fallen_back = True
logging.warning(
"Summary model '%s' not available (%s). "
"Falling back to main model '%s' for compression.",
self.summary_model, e, self.model,
)
self.summary_model = "" # empty = use main model
self._summary_failure_cooldown_until = 0.0 # no cooldown
return self._generate_summary(messages, summary_budget) # retry immediately
# Transient errors (timeout, rate limit, network) — shorter cooldown
_transient_cooldown = 60
self._summary_failure_cooldown_until = time.monotonic() + _transient_cooldown
logging.warning(
"Failed to generate context summary: %s. "
"Further summary attempts paused for %d seconds.",
e,
_SUMMARY_FAILURE_COOLDOWN_SECONDS,
_transient_cooldown,
)
return None
@@ -518,13 +873,20 @@ Write only the summary body. Do not include any preamble or prefix."""
derived from ``summary_target_ratio * context_length``, so it
scales automatically with the model's context window.
Never cuts inside a tool_call/result group. Falls back to the old
``protect_last_n`` if the budget would protect fewer messages.
Token budget is the primary criterion. A hard minimum of 3 messages
is always protected, but the budget is allowed to exceed by up to
1.5x to avoid cutting inside an oversized message (tool output, file
read, etc.). If even the minimum 3 messages exceed 1.5x the budget
the cut is placed right after the head so compression still runs.
Never cuts inside a tool_call/result group.
"""
if token_budget is None:
token_budget = self.tail_token_budget
n = len(messages)
min_tail = self.protect_last_n
# Hard minimum: always keep at least 3 messages in the tail
min_tail = min(3, n - head_end - 1) if n - head_end > 1 else 0
soft_ceiling = int(token_budget * 1.5)
accumulated = 0
cut_idx = n # start from beyond the end
@@ -537,21 +899,21 @@ Write only the summary body. Do not include any preamble or prefix."""
if isinstance(tc, dict):
args = tc.get("function", {}).get("arguments", "")
msg_tokens += len(args) // _CHARS_PER_TOKEN
if accumulated + msg_tokens > token_budget and (n - i) >= min_tail:
# Stop once we exceed the soft ceiling (unless we haven't hit min_tail yet)
if accumulated + msg_tokens > soft_ceiling and (n - i) >= min_tail:
break
accumulated += msg_tokens
cut_idx = i
# Ensure we protect at least protect_last_n messages
# Ensure we protect at least min_tail messages
fallback_cut = n - min_tail
if cut_idx > fallback_cut:
cut_idx = fallback_cut
# If the token budget would protect everything (small conversations),
# fall back to the fixed protect_last_n approach so compression can
# still remove middle turns.
# force a cut after the head so compression can still remove middle turns.
if cut_idx <= head_end:
cut_idx = fallback_cut
cut_idx = max(fallback_cut, head_end + 1)
# Align to avoid splitting tool groups
cut_idx = self._align_boundary_backward(messages, cut_idx)
@@ -562,7 +924,7 @@ Write only the summary body. Do not include any preamble or prefix."""
# Main compression entry point
# ------------------------------------------------------------------
def compress(self, messages: List[Dict[str, Any]], current_tokens: int = None) -> List[Dict[str, Any]]:
def compress(self, messages: List[Dict[str, Any]], current_tokens: int = None, focus_topic: str = None) -> List[Dict[str, Any]]:
"""Compress conversation messages by summarizing middle turns.
Algorithm:
@@ -574,14 +936,21 @@ Write only the summary body. Do not include any preamble or prefix."""
After compression, orphaned tool_call / tool_result pairs are cleaned
up so the API never receives mismatched IDs.
Args:
focus_topic: Optional focus string for guided compression. When
provided, the summariser will prioritise preserving information
related to this topic and be more aggressive about compressing
everything else. Inspired by Claude Code's ``/compact``.
"""
n_messages = len(messages)
if n_messages <= self.protect_first_n + self.protect_last_n + 1:
# Only need head + 3 tail messages minimum (token budget decides the real tail size)
_min_for_compress = self.protect_first_n + 3 + 1
if n_messages <= _min_for_compress:
if not self.quiet_mode:
logger.warning(
"Cannot compress: only %d messages (need > %d)",
n_messages,
self.protect_first_n + self.protect_last_n + 1,
n_messages, _min_for_compress,
)
return messages
@@ -589,7 +958,8 @@ Write only the summary body. Do not include any preamble or prefix."""
# Phase 1: Prune old tool results (cheap, no LLM call)
messages, pruned_count = self._prune_old_tool_results(
messages, protect_tail_count=self.protect_last_n * 3,
messages, protect_tail_count=self.protect_last_n,
protect_tail_tokens=self.tail_token_budget,
)
if pruned_count and not self.quiet_mode:
logger.info("Pre-compression: pruned %d old tool result(s)", pruned_count)
@@ -629,52 +999,67 @@ Write only the summary body. Do not include any preamble or prefix."""
)
# Phase 3: Generate structured summary
summary = self._generate_summary(turns_to_summarize)
summary = self._generate_summary(turns_to_summarize, focus_topic=focus_topic)
# Phase 4: Assemble compressed message list
compressed = []
for i in range(compress_start):
msg = messages[i].copy()
if i == 0 and msg.get("role") == "system" and self.compression_count == 0:
msg["content"] = (
(msg.get("content") or "")
+ "\n\n[Note: Some earlier conversation turns have been compacted into a handoff summary to preserve context space. The current session state may still reflect earlier work, so build on that summary and state rather than re-doing work.]"
)
if i == 0 and msg.get("role") == "system":
existing = msg.get("content") or ""
_compression_note = "[Note: Some earlier conversation turns have been compacted into a handoff summary to preserve context space. The current session state may still reflect earlier work, so build on that summary and state rather than re-doing work.]"
if _compression_note not in existing:
msg["content"] = existing + "\n\n" + _compression_note
compressed.append(msg)
_merge_summary_into_tail = False
if summary:
last_head_role = messages[compress_start - 1].get("role", "user") if compress_start > 0 else "user"
first_tail_role = messages[compress_end].get("role", "user") if compress_end < n_messages else "user"
# Pick a role that avoids consecutive same-role with both neighbors.
# Priority: avoid colliding with head (already committed), then tail.
if last_head_role in ("assistant", "tool"):
summary_role = "user"
else:
summary_role = "assistant"
# If the chosen role collides with the tail AND flipping wouldn't
# collide with the head, flip it.
if summary_role == first_tail_role:
flipped = "assistant" if summary_role == "user" else "user"
if flipped != last_head_role:
summary_role = flipped
else:
# Both roles would create consecutive same-role messages
# (e.g. head=assistant, tail=user — neither role works).
# Merge the summary into the first tail message instead
# of inserting a standalone message that breaks alternation.
_merge_summary_into_tail = True
if not _merge_summary_into_tail:
compressed.append({"role": summary_role, "content": summary})
else:
# If LLM summary failed, insert a static fallback so the model
# knows context was lost rather than silently dropping everything.
if not summary:
if not self.quiet_mode:
logger.debug("No summary model available — middle turns dropped without summary")
logger.warning("Summary generation failed — inserting static fallback context marker")
n_dropped = compress_end - compress_start
summary = (
f"{SUMMARY_PREFIX}\n"
f"Summary generation was unavailable. {n_dropped} conversation turns were "
f"removed to free context space but could not be summarized. The removed "
f"turns contained earlier work in this session. Continue based on the "
f"recent messages below and the current state of any files or resources."
)
_merge_summary_into_tail = False
last_head_role = messages[compress_start - 1].get("role", "user") if compress_start > 0 else "user"
first_tail_role = messages[compress_end].get("role", "user") if compress_end < n_messages else "user"
# Pick a role that avoids consecutive same-role with both neighbors.
# Priority: avoid colliding with head (already committed), then tail.
if last_head_role in ("assistant", "tool"):
summary_role = "user"
else:
summary_role = "assistant"
# If the chosen role collides with the tail AND flipping wouldn't
# collide with the head, flip it.
if summary_role == first_tail_role:
flipped = "assistant" if summary_role == "user" else "user"
if flipped != last_head_role:
summary_role = flipped
else:
# Both roles would create consecutive same-role messages
# (e.g. head=assistant, tail=user — neither role works).
# Merge the summary into the first tail message instead
# of inserting a standalone message that breaks alternation.
_merge_summary_into_tail = True
if not _merge_summary_into_tail:
compressed.append({"role": summary_role, "content": summary})
for i in range(compress_end, n_messages):
msg = messages[i].copy()
if _merge_summary_into_tail and i == compress_end:
original = msg.get("content") or ""
msg["content"] = summary + "\n\n" + original
msg["content"] = (
summary
+ "\n\n--- END OF CONTEXT SUMMARY — "
"respond to the message below, not the summary above ---\n\n"
+ original
)
_merge_summary_into_tail = False
compressed.append(msg)
@@ -682,14 +1067,24 @@ Write only the summary body. Do not include any preamble or prefix."""
compressed = self._sanitize_tool_pairs(compressed)
new_estimate = estimate_messages_tokens_rough(compressed)
saved_estimate = display_tokens - new_estimate
# Anti-thrashing: track compression effectiveness
savings_pct = (saved_estimate / display_tokens * 100) if display_tokens > 0 else 0
self._last_compression_savings_pct = savings_pct
if savings_pct < 10:
self._ineffective_compression_count += 1
else:
self._ineffective_compression_count = 0
if not self.quiet_mode:
new_estimate = estimate_messages_tokens_rough(compressed)
saved_estimate = display_tokens - new_estimate
logger.info(
"Compressed: %d -> %d messages (~%d tokens saved)",
"Compressed: %d -> %d messages (~%d tokens saved, %.0f%%)",
n_messages,
len(compressed),
saved_estimate,
savings_pct,
)
logger.info("Compression #%d complete", self.compression_count)

184
agent/context_engine.py Normal file
View File

@@ -0,0 +1,184 @@
"""Abstract base class for pluggable context engines.
A context engine controls how conversation context is managed when
approaching the model's token limit. The built-in ContextCompressor
is the default implementation. Third-party engines (e.g. LCM) can
replace it via the plugin system or by being placed in the
``plugins/context_engine/<name>/`` directory.
Selection is config-driven: ``context.engine`` in config.yaml.
Default is ``"compressor"`` (the built-in). Only one engine is active.
The engine is responsible for:
- Deciding when compaction should fire
- Performing compaction (summarization, DAG construction, etc.)
- Optionally exposing tools the agent can call (e.g. lcm_grep)
- Tracking token usage from API responses
Lifecycle:
1. Engine is instantiated and registered (plugin register() or default)
2. on_session_start() called when a conversation begins
3. update_from_response() called after each API response with usage data
4. should_compress() checked after each turn
5. compress() called when should_compress() returns True
6. on_session_end() called at real session boundaries (CLI exit, /reset,
gateway session expiry) — NOT per-turn
"""
from abc import ABC, abstractmethod
from typing import Any, Dict, List
class ContextEngine(ABC):
"""Base class all context engines must implement."""
# -- Identity ----------------------------------------------------------
@property
@abstractmethod
def name(self) -> str:
"""Short identifier (e.g. 'compressor', 'lcm')."""
# -- Token state (read by run_agent.py for display/logging) ------------
#
# Engines MUST maintain these. run_agent.py reads them directly.
last_prompt_tokens: int = 0
last_completion_tokens: int = 0
last_total_tokens: int = 0
threshold_tokens: int = 0
context_length: int = 0
compression_count: int = 0
# -- Compaction parameters (read by run_agent.py for preflight) --------
#
# These control the preflight compression check. Subclasses may
# override via __init__ or property; defaults are sensible for most
# engines.
threshold_percent: float = 0.75
protect_first_n: int = 3
protect_last_n: int = 6
# -- Core interface ----------------------------------------------------
@abstractmethod
def update_from_response(self, usage: Dict[str, Any]) -> None:
"""Update tracked token usage from an API response.
Called after every LLM call with the usage dict from the response.
"""
@abstractmethod
def should_compress(self, prompt_tokens: int = None) -> bool:
"""Return True if compaction should fire this turn."""
@abstractmethod
def compress(
self,
messages: List[Dict[str, Any]],
current_tokens: int = None,
) -> List[Dict[str, Any]]:
"""Compact the message list and return the new message list.
This is the main entry point. The engine receives the full message
list and returns a (possibly shorter) list that fits within the
context budget. The implementation is free to summarize, build a
DAG, or do anything else — as long as the returned list is a valid
OpenAI-format message sequence.
"""
# -- Optional: pre-flight check ----------------------------------------
def should_compress_preflight(self, messages: List[Dict[str, Any]]) -> bool:
"""Quick rough check before the API call (no real token count yet).
Default returns False (skip pre-flight). Override if your engine
can do a cheap estimate.
"""
return False
# -- Optional: session lifecycle ---------------------------------------
def on_session_start(self, session_id: str, **kwargs) -> None:
"""Called when a new conversation session begins.
Use this to load persisted state (DAG, store) for the session.
kwargs may include hermes_home, platform, model, etc.
"""
def on_session_end(self, session_id: str, messages: List[Dict[str, Any]]) -> None:
"""Called at real session boundaries (CLI exit, /reset, gateway expiry).
Use this to flush state, close DB connections, etc.
NOT called per-turn — only when the session truly ends.
"""
def on_session_reset(self) -> None:
"""Called on /new or /reset. Reset per-session state.
Default resets compression_count and token tracking.
"""
self.last_prompt_tokens = 0
self.last_completion_tokens = 0
self.last_total_tokens = 0
self.compression_count = 0
# -- Optional: tools ---------------------------------------------------
def get_tool_schemas(self) -> List[Dict[str, Any]]:
"""Return tool schemas this engine provides to the agent.
Default returns empty list (no tools). LCM would return schemas
for lcm_grep, lcm_describe, lcm_expand here.
"""
return []
def handle_tool_call(self, name: str, args: Dict[str, Any], **kwargs) -> str:
"""Handle a tool call from the agent.
Only called for tool names returned by get_tool_schemas().
Must return a JSON string.
kwargs may include:
messages: the current in-memory message list (for live ingestion)
"""
import json
return json.dumps({"error": f"Unknown context engine tool: {name}"})
# -- Optional: status / display ----------------------------------------
def get_status(self) -> Dict[str, Any]:
"""Return status dict for display/logging.
Default returns the standard fields run_agent.py expects.
"""
return {
"last_prompt_tokens": self.last_prompt_tokens,
"threshold_tokens": self.threshold_tokens,
"context_length": self.context_length,
"usage_percent": (
min(100, self.last_prompt_tokens / self.context_length * 100)
if self.context_length else 0
),
"compression_count": self.compression_count,
}
# -- Optional: model switch support ------------------------------------
def update_model(
self,
model: str,
context_length: int,
base_url: str = "",
api_key: str = "",
provider: str = "",
) -> None:
"""Called when the user switches models or on fallback activation.
Default updates context_length and recalculates threshold_tokens
from threshold_percent. Override if your engine needs more
(e.g. recalculate DAG budgets, switch summary models).
"""
self.context_length = context_length
self.threshold_tokens = int(context_length * self.threshold_percent)

View File

@@ -13,8 +13,9 @@ from typing import Awaitable, Callable
from agent.model_metadata import estimate_tokens_rough
_QUOTED_REFERENCE_VALUE = r'(?:`[^`\n]+`|"[^"\n]+"|\'[^\'\n]+\')'
REFERENCE_PATTERN = re.compile(
r"(?<![\w/])@(?:(?P<simple>diff|staged)\b|(?P<kind>file|folder|git|url):(?P<value>\S+))"
rf"(?<![\w/])@(?:(?P<simple>diff|staged)\b|(?P<kind>file|folder|git|url):(?P<value>{_QUOTED_REFERENCE_VALUE}(?::\d+(?:-\d+)?)?|\S+))"
)
TRAILING_PUNCTUATION = ",.;!?"
_SENSITIVE_HOME_DIRS = (".ssh", ".aws", ".gnupg", ".kube", ".docker", ".azure", ".config/gh")
@@ -81,14 +82,10 @@ def parse_context_references(message: str) -> list[ContextReference]:
value = _strip_trailing_punctuation(match.group("value") or "")
line_start = None
line_end = None
target = value
target = _strip_reference_wrappers(value)
if kind == "file":
range_match = re.match(r"^(?P<path>.+?):(?P<start>\d+)(?:-(?P<end>\d+))?$", value)
if range_match:
target = range_match.group("path")
line_start = int(range_match.group("start"))
line_end = int(range_match.group("end") or range_match.group("start"))
target, line_start, line_end = _parse_file_reference_value(value)
refs.append(
ContextReference(
@@ -343,10 +340,9 @@ def _resolve_path(cwd: Path, target: str, *, allowed_root: Path | None = None) -
def _ensure_reference_path_allowed(path: Path) -> None:
from hermes_constants import get_hermes_home
home = Path(os.path.expanduser("~")).resolve()
hermes_home = Path(
os.getenv("HERMES_HOME", str(home / ".hermes"))
).expanduser().resolve()
hermes_home = get_hermes_home().resolve()
blocked_exact = {home / rel for rel in _SENSITIVE_HOME_FILES}
blocked_exact.add(hermes_home / ".env")
@@ -376,6 +372,38 @@ def _strip_trailing_punctuation(value: str) -> str:
return stripped
def _strip_reference_wrappers(value: str) -> str:
if len(value) >= 2 and value[0] == value[-1] and value[0] in "`\"'":
return value[1:-1]
return value
def _parse_file_reference_value(value: str) -> tuple[str, int | None, int | None]:
quoted_match = re.match(
r'^(?P<quote>`|"|\')(?P<path>.+?)(?P=quote)(?::(?P<start>\d+)(?:-(?P<end>\d+))?)?$',
value,
)
if quoted_match:
line_start = quoted_match.group("start")
line_end = quoted_match.group("end")
return (
quoted_match.group("path"),
int(line_start) if line_start is not None else None,
int(line_end or line_start) if line_start is not None else None,
)
range_match = re.match(r"^(?P<path>.+?):(?P<start>\d+)(?:-(?P<end>\d+))?$", value)
if range_match:
line_start = int(range_match.group("start"))
return (
range_match.group("path"),
line_start,
int(range_match.group("end") or range_match.group("start")),
)
return _strip_reference_wrappers(value), None, None
def _remove_reference_tokens(message: str, refs: list[ContextReference]) -> str:
pieces: list[str] = []
cursor = 0

View File

@@ -10,24 +10,26 @@ import uuid
import os
import re
from dataclasses import dataclass, fields, replace
from datetime import datetime, timezone
from datetime import datetime
from typing import Any, Dict, List, Optional, Set, Tuple
from hermes_constants import OPENROUTER_BASE_URL
import hermes_cli.auth as auth_mod
from hermes_cli.auth import (
ACCESS_TOKEN_REFRESH_SKEW_SECONDS,
CODEX_ACCESS_TOKEN_REFRESH_SKEW_SECONDS,
DEFAULT_AGENT_KEY_MIN_TTL_SECONDS,
PROVIDER_REGISTRY,
_agent_key_is_usable,
_auth_store_lock,
_codex_access_token_is_expiring,
_decode_jwt_claims,
_import_codex_cli_tokens,
_is_expiring,
_write_codex_cli_tokens,
_load_auth_store,
_load_provider_state,
_resolve_kimi_base_url,
_resolve_zai_base_url,
_save_auth_store,
_save_provider_state,
read_credential_pool,
write_credential_pool,
)
@@ -67,10 +69,10 @@ SUPPORTED_POOL_STRATEGIES = {
}
# Cooldown before retrying an exhausted credential.
# 429 (rate-limited) cools down faster since quotas reset frequently.
# 402 (billing/quota) and other codes use a longer default.
# 429 (rate-limited) and 402 (billing/quota) both cool down after 1 hour.
# Provider-supplied reset_at timestamps override these defaults.
EXHAUSTED_TTL_429_SECONDS = 60 * 60 # 1 hour
EXHAUSTED_TTL_DEFAULT_SECONDS = 24 * 60 * 60 # 24 hours
EXHAUSTED_TTL_DEFAULT_SECONDS = 60 * 60 # 1 hour
# Pool key prefix for custom OpenAI-compatible endpoints.
# Custom endpoints all share provider='custom' but are keyed by their
@@ -286,6 +288,14 @@ def _iter_custom_providers(config: Optional[dict] = None):
return
custom_providers = config.get("custom_providers")
if not isinstance(custom_providers, list):
# Fall back to the v12+ providers dict via the compatibility layer
try:
from hermes_cli.config import get_compatible_custom_providers
custom_providers = get_compatible_custom_providers(config)
except Exception:
return
if not custom_providers:
return
for entry in custom_providers:
if not isinstance(entry, dict):
@@ -480,6 +490,67 @@ class CredentialPool:
logger.debug("Failed to sync from ~/.codex/auth.json: %s", exc)
return entry
def _sync_device_code_entry_to_auth_store(self, entry: PooledCredential) -> None:
"""Write refreshed pool entry tokens back to auth.json providers.
After a pool-level refresh, the pool entry has fresh tokens but
auth.json's ``providers.<id>`` still holds the pre-refresh state.
On the next ``load_pool()``, ``_seed_from_singletons()`` reads that
stale state and can overwrite the fresh pool entry — potentially
re-seeding a consumed single-use refresh token.
Applies to any OAuth provider whose singleton lives in auth.json
(currently Nous and OpenAI Codex).
"""
if entry.source != "device_code":
return
try:
with _auth_store_lock():
auth_store = _load_auth_store()
if self.provider == "nous":
state = _load_provider_state(auth_store, "nous")
if state is None:
return
state["access_token"] = entry.access_token
if entry.refresh_token:
state["refresh_token"] = entry.refresh_token
if entry.expires_at:
state["expires_at"] = entry.expires_at
if entry.agent_key:
state["agent_key"] = entry.agent_key
if entry.agent_key_expires_at:
state["agent_key_expires_at"] = entry.agent_key_expires_at
for extra_key in ("obtained_at", "expires_in", "agent_key_id",
"agent_key_expires_in", "agent_key_reused",
"agent_key_obtained_at"):
val = entry.extra.get(extra_key)
if val is not None:
state[extra_key] = val
if entry.inference_base_url:
state["inference_base_url"] = entry.inference_base_url
_save_provider_state(auth_store, "nous", state)
elif self.provider == "openai-codex":
state = _load_provider_state(auth_store, "openai-codex")
if not isinstance(state, dict):
return
tokens = state.get("tokens")
if not isinstance(tokens, dict):
return
tokens["access_token"] = entry.access_token
if entry.refresh_token:
tokens["refresh_token"] = entry.refresh_token
if entry.last_refresh:
state["last_refresh"] = entry.last_refresh
_save_provider_state(auth_store, "openai-codex", state)
else:
return
_save_auth_store(auth_store)
except Exception as exc:
logger.debug("Failed to sync %s pool entry back to auth store: %s", self.provider, exc)
def _refresh_entry(self, entry: PooledCredential, *, force: bool) -> Optional[PooledCredential]:
if entry.auth_type != AUTH_TYPE_OAUTH or not entry.refresh_token:
if force:
@@ -514,6 +585,13 @@ class CredentialPool:
except Exception as wexc:
logger.debug("Failed to write refreshed token to credentials file: %s", wexc)
elif self.provider == "openai-codex":
# Proactively sync from ~/.codex/auth.json before refresh.
# The Codex CLI (or another Hermes profile) may have already
# consumed our refresh_token. Syncing first avoids a
# "refresh_token_reused" error when the CLI has a newer pair.
synced = self._sync_codex_entry_from_cli(entry)
if synced is not entry:
entry = synced
refreshed = auth_mod.refresh_codex_oauth_pure(
entry.access_token,
entry.refresh_token,
@@ -599,6 +677,45 @@ class CredentialPool:
# Credentials file had a valid (non-expired) token — use it directly
logger.debug("Credentials file has valid token, using without refresh")
return synced
# For openai-codex: the refresh_token may have been consumed by
# the Codex CLI between our proactive sync and the refresh call.
# Re-sync and retry once.
if self.provider == "openai-codex":
synced = self._sync_codex_entry_from_cli(entry)
if synced.refresh_token != entry.refresh_token:
logger.debug("Retrying Codex refresh with synced token from ~/.codex/auth.json")
try:
refreshed = auth_mod.refresh_codex_oauth_pure(
synced.access_token,
synced.refresh_token,
)
updated = replace(
synced,
access_token=refreshed["access_token"],
refresh_token=refreshed["refresh_token"],
last_refresh=refreshed.get("last_refresh"),
last_status=STATUS_OK,
last_status_at=None,
last_error_code=None,
)
self._replace_entry(synced, updated)
self._persist()
self._sync_device_code_entry_to_auth_store(updated)
try:
_write_codex_cli_tokens(
updated.access_token,
updated.refresh_token,
last_refresh=updated.last_refresh,
)
except Exception as wexc:
logger.debug("Failed to write refreshed Codex tokens to CLI file (retry): %s", wexc)
return updated
except Exception as retry_exc:
logger.debug("Codex retry refresh also failed: %s", retry_exc)
elif not self._entry_needs_refresh(synced):
logger.debug("Codex CLI has valid token, using without refresh")
self._sync_device_code_entry_to_auth_store(synced)
return synced
self._mark_exhausted(entry, None)
return None
@@ -613,6 +730,21 @@ class CredentialPool:
)
self._replace_entry(entry, updated)
self._persist()
# Sync refreshed tokens back to auth.json providers so that
# _seed_from_singletons() on the next load_pool() sees fresh state
# instead of re-seeding stale/consumed tokens.
self._sync_device_code_entry_to_auth_store(updated)
# Write refreshed tokens back to ~/.codex/auth.json so Codex CLI
# and VS Code don't hit "refresh_token_reused" on their next refresh.
if self.provider == "openai-codex":
try:
_write_codex_cli_tokens(
updated.access_token,
updated.refresh_token,
last_refresh=updated.last_refresh,
)
except Exception as wexc:
logger.debug("Failed to write refreshed Codex tokens to CLI file: %s", wexc)
return updated
def _entry_needs_refresh(self, entry: PooledCredential) -> bool:
@@ -634,17 +766,6 @@ class CredentialPool:
return False
return False
def mark_used(self, entry_id: Optional[str] = None) -> None:
"""Increment request_count for tracking. Used by least_used strategy."""
target_id = entry_id or self._current_id
if not target_id:
return
with self._lock:
for idx, entry in enumerate(self._entries):
if entry.id == target_id:
self._entries[idx] = replace(entry, request_count=entry.request_count + 1)
return
def select(self) -> Optional[PooledCredential]:
with self._lock:
return self._select_unlocked()
@@ -806,11 +927,6 @@ class CredentialPool:
else:
self._active_leases[credential_id] = count - 1
def active_lease_count(self, credential_id: str) -> int:
"""Return the number of active leases for a credential."""
with self._lock:
return self._active_leases.get(credential_id, 0)
def try_refresh_current(self) -> Optional[PooledCredential]:
with self._lock:
return self._try_refresh_current_unlocked()
@@ -970,6 +1086,17 @@ def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tup
auth_store = _load_auth_store()
if provider == "anthropic":
# Only auto-discover external credentials (Claude Code, Hermes PKCE)
# when the user has explicitly configured anthropic as their provider.
# Without this gate, auxiliary client fallback chains silently read
# ~/.claude/.credentials.json without user consent. See PR #4210.
try:
from hermes_cli.auth import is_provider_explicitly_configured
if not is_provider_explicitly_configured("anthropic"):
return changed, active_sources
except ImportError:
pass
from agent.anthropic_adapter import read_claude_code_credentials, read_hermes_oauth_credentials
for source_name, creds in (
@@ -977,6 +1104,13 @@ def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tup
("claude_code", read_claude_code_credentials()),
):
if creds and creds.get("accessToken"):
# Check if user explicitly removed this source
try:
from hermes_cli.auth import is_source_suppressed
if is_source_suppressed(provider, source_name):
continue
except ImportError:
pass
active_sources.add(source_name)
changed |= _upsert_entry(
entries,
@@ -1018,9 +1152,79 @@ def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tup
},
)
elif provider == "copilot":
# Copilot tokens are resolved dynamically via `gh auth token` or
# env vars (COPILOT_GITHUB_TOKEN / GH_TOKEN). They don't live in
# the auth store or credential pool, so we resolve them here.
try:
from hermes_cli.copilot_auth import resolve_copilot_token
token, source = resolve_copilot_token()
if token:
source_name = "gh_cli" if "gh" in source.lower() else f"env:{source}"
active_sources.add(source_name)
changed |= _upsert_entry(
entries,
provider,
source_name,
{
"source": source_name,
"auth_type": AUTH_TYPE_API_KEY,
"access_token": token,
"label": source,
},
)
except Exception as exc:
logger.debug("Copilot token seed failed: %s", exc)
elif provider == "qwen-oauth":
# Qwen OAuth tokens live in ~/.qwen/oauth_creds.json, written by
# the Qwen CLI (`qwen auth qwen-oauth`). They aren't in the
# Hermes auth store or env vars, so resolve them here.
# Use refresh_if_expiring=False to avoid network calls during
# pool loading / provider discovery.
try:
from hermes_cli.auth import resolve_qwen_runtime_credentials
creds = resolve_qwen_runtime_credentials(refresh_if_expiring=False)
token = creds.get("api_key", "")
if token:
source_name = creds.get("source", "qwen-cli")
active_sources.add(source_name)
changed |= _upsert_entry(
entries,
provider,
source_name,
{
"source": source_name,
"auth_type": AUTH_TYPE_OAUTH,
"access_token": token,
"expires_at_ms": creds.get("expires_at_ms"),
"base_url": creds.get("base_url", ""),
"label": creds.get("auth_file", source_name),
},
)
except Exception as exc:
logger.debug("Qwen OAuth token seed failed: %s", exc)
elif provider == "openai-codex":
state = _load_provider_state(auth_store, "openai-codex")
tokens = state.get("tokens") if isinstance(state, dict) else None
# Fallback: import from Codex CLI (~/.codex/auth.json) if Hermes auth
# store has no tokens. This mirrors resolve_codex_runtime_credentials()
# so that load_pool() and list_authenticated_providers() detect tokens
# that only exist in the Codex CLI shared file.
if not (isinstance(tokens, dict) and tokens.get("access_token")):
try:
from hermes_cli.auth import _import_codex_cli_tokens, _save_codex_tokens
cli_tokens = _import_codex_cli_tokens()
if cli_tokens:
logger.info("Importing Codex CLI tokens into Hermes auth store.")
_save_codex_tokens(cli_tokens)
# Re-read state after import
auth_store = _load_auth_store()
state = _load_provider_state(auth_store, "openai-codex")
tokens = state.get("tokens") if isinstance(state, dict) else None
except Exception as exc:
logger.debug("Codex CLI token import failed: %s", exc)
if isinstance(tokens, dict) and tokens.get("access_token"):
active_sources.add("device_code")
changed |= _upsert_entry(
@@ -1087,7 +1291,9 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool
active_sources.add(source)
auth_type = AUTH_TYPE_OAUTH if provider == "anthropic" and not token.startswith("sk-ant-api") else AUTH_TYPE_API_KEY
base_url = env_url or pconfig.inference_base_url
if provider == "zai":
if provider == "kimi-coding":
base_url = _resolve_kimi_base_url(token, pconfig.inference_base_url, env_url)
elif provider == "zai":
base_url = _resolve_zai_base_url(token, pconfig.inference_base_url, env_url)
changed |= _upsert_entry(
entries,

149
agent/crisis_resources.py Normal file
View File

@@ -0,0 +1,149 @@
"""
988 Suicide & Crisis Lifeline Integration (#673).
When crisis is detected, provides immediate access to help:
- Phone: 988 (call or text)
- Text: Text HOME to 988
- Chat: 988lifeline.org/chat
- Spanish: 1-888-628-9454
- Emergency: 911
This module provides the resource data. agent/crisis_protocol.py
handles detection. This module formats the resources for display.
"""
from dataclasses import dataclass
from typing import List
@dataclass
class CrisisResource:
"""A crisis support contact method."""
name: str
contact: str
description: str
url: str = ""
available: str = "24/7"
language: str = "English"
# 988 Suicide & Crisis Lifeline — all channels
LIFELINE_988 = CrisisResource(
name="988 Suicide and Crisis Lifeline",
contact="Call or text 988",
description="Free, confidential support for people in suicidal crisis or emotional distress.",
url="https://988lifeline.org",
available="24/7",
language="English",
)
LIFELINE_988_TEXT = CrisisResource(
name="988 Crisis Text Line",
contact="Text HOME to 988",
description="Free, 24/7 crisis support via text message.",
url="",
available="24/7",
language="English",
)
LIFELINE_988_CHAT = CrisisResource(
name="988 Lifeline Chat",
contact="988lifeline.org/chat",
description="Free, confidential online chat with a trained crisis counselor.",
url="https://988lifeline.org/chat",
available="24/7",
language="English",
)
LIFELINE_988_SPANISH = CrisisResource(
name="988 Lifeline (Spanish)",
contact="1-888-628-9454",
description="Línea de prevención del suicidio en español.",
url="https://988lifeline.org/help-yourself/en-espanol/",
available="24/7",
language="Spanish",
)
CRISIS_TEXT_LINE = CrisisResource(
name="Crisis Text Line",
contact="Text HOME to 741741",
description="Free, 24/7 crisis support via text message.",
url="https://www.crisistextline.org",
available="24/7",
language="English",
)
EMERGENCY_911 = CrisisResource(
name="Emergency Services",
contact="911",
description="Immediate danger — police, fire, ambulance.",
url="",
available="24/7",
language="Any",
)
# All resources in priority order
ALL_RESOURCES: List[CrisisResource] = [
EMERGENCY_911,
LIFELINE_988,
LIFELINE_988_TEXT,
LIFELINE_988_CHAT,
CRISIS_TEXT_LINE,
LIFELINE_988_SPANISH,
]
def get_crisis_resources(language: str = None) -> List[CrisisResource]:
"""Get crisis resources, optionally filtered by language.
Args:
language: Filter by language ("English", "Spanish", or None for all)
Returns:
List of CrisisResource objects
"""
if language:
return [r for r in ALL_RESOURCES if r.language.lower() == language.lower()]
return ALL_RESOURCES
def format_crisis_resources(resources: List[CrisisResource] = None) -> str:
"""Format crisis resources as a user-facing message.
Args:
resources: List of resources to format. Defaults to all resources.
Returns:
Formatted string suitable for displaying to a user in crisis.
"""
if resources is None:
resources = ALL_RESOURCES
lines = ["**Please reach out — help is available right now:**
"]
for r in resources:
if r.url:
lines.append(f"- **{r.name}:** {r.contact} ({r.url})")
else:
lines.append(f"- **{r.name}:** {r.contact}")
lines.append("")
lines.append("All services are free, confidential, and available 24/7.")
lines.append("You are not alone.")
return "
".join(lines)
def get_immediate_help_message() -> str:
"""Get the most urgent crisis help message.
Used when crisis is detected at CRITICAL level.
"""
return (
"If you are in immediate danger, call **911** right now.
"
+ format_crisis_resources()
)

View File

@@ -4,7 +4,6 @@ Pure display functions and classes with no AIAgent dependency.
Used by AIAgent._execute_tool_calls for CLI feedback.
"""
import json
import logging
import os
import sys
@@ -14,6 +13,8 @@ from dataclasses import dataclass, field
from difflib import unified_diff
from pathlib import Path
from utils import safe_json_loads
# ANSI escape codes for coloring tool failure indicators
_RED = "\033[31m"
_RESET = "\033[0m"
@@ -21,11 +22,67 @@ _RESET = "\033[0m"
logger = logging.getLogger(__name__)
_ANSI_RESET = "\033[0m"
_ANSI_DIM = "\033[38;2;150;150;150m"
_ANSI_FILE = "\033[38;2;180;160;255m"
_ANSI_HUNK = "\033[38;2;120;120;140m"
_ANSI_MINUS = "\033[38;2;255;255;255;48;2;120;20;20m"
_ANSI_PLUS = "\033[38;2;255;255;255;48;2;20;90;20m"
# Diff colors — resolved lazily from the skin engine so they adapt
# to light/dark themes. Falls back to sensible defaults on import
# failure. We cache after first resolution for performance.
_diff_colors_cached: dict[str, str] | None = None
def _diff_ansi() -> dict[str, str]:
"""Return ANSI escapes for diff display, resolved from the active skin."""
global _diff_colors_cached
if _diff_colors_cached is not None:
return _diff_colors_cached
# Defaults that work on dark terminals
dim = "\033[38;2;150;150;150m"
file_c = "\033[38;2;180;160;255m"
hunk = "\033[38;2;120;120;140m"
minus = "\033[38;2;255;255;255;48;2;120;20;20m"
plus = "\033[38;2;255;255;255;48;2;20;90;20m"
try:
from hermes_cli.skin_engine import get_active_skin
skin = get_active_skin()
def _hex_fg(key: str, fallback_rgb: tuple[int, int, int]) -> str:
h = skin.get_color(key, "")
if h and len(h) == 7 and h[0] == "#":
r, g, b = int(h[1:3], 16), int(h[3:5], 16), int(h[5:7], 16)
return f"\033[38;2;{r};{g};{b}m"
r, g, b = fallback_rgb
return f"\033[38;2;{r};{g};{b}m"
dim = _hex_fg("banner_dim", (150, 150, 150))
file_c = _hex_fg("session_label", (180, 160, 255))
hunk = _hex_fg("session_border", (120, 120, 140))
# minus/plus use background colors — derive from ui_error/ui_ok
err_h = skin.get_color("ui_error", "#ef5350")
ok_h = skin.get_color("ui_ok", "#4caf50")
if err_h and len(err_h) == 7:
er, eg, eb = int(err_h[1:3], 16), int(err_h[3:5], 16), int(err_h[5:7], 16)
# Use a dark tinted version as background
minus = f"\033[38;2;255;255;255;48;2;{max(er//2,20)};{max(eg//4,10)};{max(eb//4,10)}m"
if ok_h and len(ok_h) == 7:
or_, og, ob = int(ok_h[1:3], 16), int(ok_h[3:5], 16), int(ok_h[5:7], 16)
plus = f"\033[38;2;255;255;255;48;2;{max(or_//4,10)};{max(og//2,20)};{max(ob//4,10)}m"
except Exception:
pass
_diff_colors_cached = {
"dim": dim, "file": file_c, "hunk": hunk,
"minus": minus, "plus": plus,
}
return _diff_colors_cached
# Module-level helpers — each call resolves from the active skin lazily.
def _diff_dim(): return _diff_ansi()["dim"]
def _diff_file(): return _diff_ansi()["file"]
def _diff_hunk(): return _diff_ansi()["hunk"]
def _diff_minus(): return _diff_ansi()["minus"]
def _diff_plus(): return _diff_ansi()["plus"]
_MAX_INLINE_DIFF_FILES = 6
_MAX_INLINE_DIFF_LINES = 80
@@ -67,26 +124,6 @@ def _get_skin():
return None
def get_skin_faces(key: str, default: list) -> list:
"""Get spinner face list from active skin, falling back to default."""
skin = _get_skin()
if skin:
faces = skin.get_spinner_list(key)
if faces:
return faces
return default
def get_skin_verbs() -> list:
"""Get thinking verbs from active skin."""
skin = _get_skin()
if skin:
verbs = skin.get_spinner_list("thinking_verbs")
if verbs:
return verbs
return KawaiiSpinner.THINKING_VERBS
def get_skin_tool_prefix() -> str:
"""Get tool output prefix character from active skin."""
skin = _get_skin()
@@ -330,9 +367,8 @@ def _result_succeeded(result: str | None) -> bool:
"""Conservatively detect whether a tool result represents success."""
if not result:
return False
try:
data = json.loads(result)
except (json.JSONDecodeError, TypeError):
data = safe_json_loads(result)
if data is None:
return False
if not isinstance(data, dict):
return False
@@ -381,10 +417,7 @@ def extract_edit_diff(
) -> str | None:
"""Extract a unified diff from a file-edit tool result."""
if tool_name == "patch" and result:
try:
data = json.loads(result)
except (json.JSONDecodeError, TypeError):
data = None
data = safe_json_loads(result)
if isinstance(data, dict):
diff = data.get("diff")
if isinstance(diff, str) and diff.strip():
@@ -423,19 +456,19 @@ def _render_inline_unified_diff(diff: str) -> list[str]:
if raw_line.startswith("+++ "):
to_file = raw_line[4:].strip()
if from_file or to_file:
rendered.append(f"{_ANSI_FILE}{from_file or 'a/?'}{to_file or 'b/?'}{_ANSI_RESET}")
rendered.append(f"{_diff_file()}{from_file or 'a/?'}{to_file or 'b/?'}{_ANSI_RESET}")
continue
if raw_line.startswith("@@"):
rendered.append(f"{_ANSI_HUNK}{raw_line}{_ANSI_RESET}")
rendered.append(f"{_diff_hunk()}{raw_line}{_ANSI_RESET}")
continue
if raw_line.startswith("-"):
rendered.append(f"{_ANSI_MINUS}{raw_line}{_ANSI_RESET}")
rendered.append(f"{_diff_minus()}{raw_line}{_ANSI_RESET}")
continue
if raw_line.startswith("+"):
rendered.append(f"{_ANSI_PLUS}{raw_line}{_ANSI_RESET}")
rendered.append(f"{_diff_plus()}{raw_line}{_ANSI_RESET}")
continue
if raw_line.startswith(" "):
rendered.append(f"{_ANSI_DIM}{raw_line}{_ANSI_RESET}")
rendered.append(f"{_diff_dim()}{raw_line}{_ANSI_RESET}")
continue
if raw_line:
rendered.append(raw_line)
@@ -501,7 +534,7 @@ def _summarize_rendered_diff_sections(
summary = f"… omitted {omitted_lines} diff line(s)"
if omitted_files:
summary += f" across {omitted_files} additional file(s)/section(s)"
rendered.append(f"{_ANSI_HUNK}{summary}{_ANSI_RESET}")
rendered.append(f"{_diff_hunk()}{summary}{_ANSI_RESET}")
return rendered
@@ -723,46 +756,6 @@ class KawaiiSpinner:
return False
# =========================================================================
# Kawaii face arrays (used by AIAgent._execute_tool_calls for spinner text)
# =========================================================================
KAWAII_SEARCH = [
"♪(´ε` )", "(。◕‿◕。)", "ヾ(^∇^)", "(◕ᴗ◕✿)", "( ˘▽˘)っ",
"٩(◕‿◕。)۶", "(✿◠‿◠)", "♪~(´ε` )", "(ノ´ヮ`)*:・゚✧", "(◎o◎)",
]
KAWAII_READ = [
"φ(゜▽゜*)♪", "( ˘▽˘)っ", "(⌐■_■)", "٩(。•́‿•̀。)۶", "(◕‿◕✿)",
"ヾ(@⌒ー⌒@)", "(✧ω✧)", "♪(๑ᴖ◡ᴖ๑)♪", "(≧◡≦)", "( ´ ▽ ` )",
]
KAWAII_TERMINAL = [
"ヽ(>∀<☆)", "(ノ°∀°)", "٩(^ᴗ^)۶", "ヾ(⌐■_■)ノ♪", "(•̀ᴗ•́)و",
"┗(0)┓", "(`・ω・´)", "( ̄▽ ̄)", "(ง •̀_•́)ง", "ヽ(´▽`)/",
]
KAWAII_BROWSER = [
"(ノ°∀°)", "(☞゚ヮ゚)☞", "( ͡° ͜ʖ ͡°)", "┌( ಠ_ಠ)┘", "(⊙_⊙)",
"ヾ(•ω•`)o", "( ̄ω ̄)", "( ˇωˇ )", "(ᵔᴥᵔ)", "(◎o◎)",
]
KAWAII_CREATE = [
"✧*。٩(ˊᗜˋ*)و✧", "(ノ◕ヮ◕)ノ*:・゚✧", "ヽ(>∀<☆)", "٩(♡ε♡)۶", "(◕‿◕)♡",
"✿◕ ‿ ◕✿", "(*≧▽≦)", "ヾ(-)", "(☆▽☆)", "°˖✧◝(⁰▿⁰)◜✧˖°",
]
KAWAII_SKILL = [
"ヾ(@⌒ー⌒@)", "(๑˃ᴗ˂)ﻭ", "٩(◕‿◕。)۶", "(✿╹◡╹)", "ヽ(・∀・)",
"(ノ´ヮ`)*:・゚✧", "♪(๑ᴖ◡ᴖ๑)♪", "(◠‿◠)", "٩(ˊᗜˋ*)و", "(^▽^)",
"ヾ(^∇^)", "(★ω★)/", "٩(。•́‿•̀。)۶", "(◕ᴗ◕✿)", "(◎o◎)",
"(✧ω✧)", "ヽ(>∀<☆)", "( ˘▽˘)っ", "(≧◡≦) ♡", "ヾ( ̄▽ ̄)",
]
KAWAII_THINK = [
"(っ°Д°;)っ", "(;′⌒`)", "(・_・ヾ", "( ´_ゝ`)", "( ̄ヘ ̄)",
"(。-`ω´-)", "( ˘︹˘ )", "(¬_¬)", "ヽ(ー_ー )", "(一_一)",
]
KAWAII_GENERIC = [
"♪(´ε` )", "(◕‿◕✿)", "ヾ(^∇^)", "٩(◕‿◕。)۶", "(✿◠‿◠)",
"(ノ´ヮ`)*:・゚✧", "ヽ(>∀<☆)", "(☆▽☆)", "( ˘▽˘)っ", "(≧◡≦)",
]
# =========================================================================
# Cute tool message (completion line that replaces the spinner)
# =========================================================================
@@ -778,23 +771,19 @@ def _detect_tool_failure(tool_name: str, result: str | None) -> tuple[bool, str]
return False, ""
if tool_name == "terminal":
try:
data = json.loads(result)
data = safe_json_loads(result)
if isinstance(data, dict):
exit_code = data.get("exit_code")
if exit_code is not None and exit_code != 0:
return True, f" [exit {exit_code}]"
except (json.JSONDecodeError, TypeError, AttributeError):
logger.debug("Could not parse terminal result as JSON for exit code check")
return False, ""
# Memory-specific: distinguish "full" from real errors
if tool_name == "memory":
try:
data = json.loads(result)
data = safe_json_loads(result)
if isinstance(data, dict):
if data.get("success") is False and "exceed the limit" in data.get("error", ""):
return True, " [full]"
except (json.JSONDecodeError, TypeError, AttributeError):
logger.debug("Could not parse memory result as JSON for capacity check")
# Generic heuristic for non-terminal tools
lower = result[:500].lower()
@@ -970,40 +959,6 @@ _SKY_BLUE = "\033[38;5;117m"
_ANSI_RESET = "\033[0m"
def honcho_session_url(workspace: str, session_name: str) -> str:
"""Build a Honcho app URL for a session."""
from urllib.parse import quote
return (
f"https://app.honcho.dev/explore"
f"?workspace={quote(workspace, safe='')}"
f"&view=sessions"
f"&session={quote(session_name, safe='')}"
)
def _osc8_link(url: str, text: str) -> str:
"""OSC 8 terminal hyperlink (clickable in iTerm2, Ghostty, WezTerm, etc.)."""
return f"\033]8;;{url}\033\\{text}\033]8;;\033\\"
def honcho_session_line(workspace: str, session_name: str) -> str:
"""One-line session indicator: `Honcho session: <clickable name>`."""
url = honcho_session_url(workspace, session_name)
linked_name = _osc8_link(url, f"{_SKY_BLUE}{session_name}{_ANSI_RESET}")
return f"{_DIM}Honcho session:{_ANSI_RESET} {linked_name}"
def write_tty(text: str) -> None:
"""Write directly to /dev/tty, bypassing stdout capture."""
try:
fd = os.open("/dev/tty", os.O_WRONLY)
os.write(fd, text.encode("utf-8"))
os.close(fd)
except OSError:
sys.stdout.write(text)
sys.stdout.flush()
# =========================================================================
# Context pressure display (CLI user-facing warnings)
# =========================================================================

820
agent/error_classifier.py Normal file
View File

@@ -0,0 +1,820 @@
"""API error classification for smart failover and recovery.
Provides a structured taxonomy of API errors and a priority-ordered
classification pipeline that determines the correct recovery action
(retry, rotate credential, fallback to another provider, compress
context, or abort).
Replaces scattered inline string-matching with a centralized classifier
that the main retry loop in run_agent.py consults for every API failure.
"""
from __future__ import annotations
import enum
import logging
from dataclasses import dataclass, field
from typing import Any, Dict, Optional
logger = logging.getLogger(__name__)
# ── Error taxonomy ──────────────────────────────────────────────────────
class FailoverReason(enum.Enum):
"""Why an API call failed — determines recovery strategy."""
# Authentication / authorization
auth = "auth" # Transient auth (401/403) — refresh/rotate
auth_permanent = "auth_permanent" # Auth failed after refresh — abort
# Billing / quota
billing = "billing" # 402 or confirmed credit exhaustion — rotate immediately
rate_limit = "rate_limit" # 429 or quota-based throttling — backoff then rotate
# Server-side
overloaded = "overloaded" # 503/529 — provider overloaded, backoff
server_error = "server_error" # 500/502 — internal server error, retry
# Transport
timeout = "timeout" # Connection/read timeout — rebuild client + retry
# Context / payload
context_overflow = "context_overflow" # Context too large — compress, not failover
payload_too_large = "payload_too_large" # 413 — compress payload
# Model
model_not_found = "model_not_found" # 404 or invalid model — fallback to different model
# Request format
format_error = "format_error" # 400 bad request — abort or strip + retry
# Provider-specific
thinking_signature = "thinking_signature" # Anthropic thinking block sig invalid
long_context_tier = "long_context_tier" # Anthropic "extra usage" tier gate
# Catch-all
unknown = "unknown" # Unclassifiable — retry with backoff
# ── Classification result ───────────────────────────────────────────────
@dataclass
class ClassifiedError:
"""Structured classification of an API error with recovery hints."""
reason: FailoverReason
status_code: Optional[int] = None
provider: Optional[str] = None
model: Optional[str] = None
message: str = ""
error_context: Dict[str, Any] = field(default_factory=dict)
# Recovery action hints — the retry loop checks these instead of
# re-classifying the error itself.
retryable: bool = True
should_compress: bool = False
should_rotate_credential: bool = False
should_fallback: bool = False
@property
def is_auth(self) -> bool:
return self.reason in (FailoverReason.auth, FailoverReason.auth_permanent)
# ── Provider-specific patterns ──────────────────────────────────────────
# Patterns that indicate billing exhaustion (not transient rate limit)
_BILLING_PATTERNS = [
"insufficient credits",
"insufficient_quota",
"credit balance",
"credits have been exhausted",
"top up your credits",
"payment required",
"billing hard limit",
"exceeded your current quota",
"account is deactivated",
"plan does not include",
]
# Patterns that indicate rate limiting (transient, will resolve)
_RATE_LIMIT_PATTERNS = [
"rate limit",
"rate_limit",
"too many requests",
"throttled",
"requests per minute",
"tokens per minute",
"requests per day",
"try again in",
"please retry after",
"resource_exhausted",
"rate increased too quickly", # Alibaba/DashScope throttling
]
# Usage-limit patterns that need disambiguation (could be billing OR rate_limit)
_USAGE_LIMIT_PATTERNS = [
"usage limit",
"quota",
"limit exceeded",
"key limit exceeded",
]
# Patterns confirming usage limit is transient (not billing)
_USAGE_LIMIT_TRANSIENT_SIGNALS = [
"try again",
"retry",
"resets at",
"reset in",
"wait",
"requests remaining",
"periodic",
"window",
]
# Payload-too-large patterns detected from message text (no status_code attr).
# Proxies and some backends embed the HTTP status in the error message.
_PAYLOAD_TOO_LARGE_PATTERNS = [
"request entity too large",
"payload too large",
"error code: 413",
]
# Context overflow patterns
_CONTEXT_OVERFLOW_PATTERNS = [
"context length",
"context size",
"maximum context",
"token limit",
"too many tokens",
"reduce the length",
"exceeds the limit",
"context window",
"prompt is too long",
"prompt exceeds max length",
"max_tokens",
"maximum number of tokens",
# vLLM / local inference server patterns
"exceeds the max_model_len",
"max_model_len",
"prompt length", # "engine prompt length X exceeds"
"input is too long",
"maximum model length",
# Ollama patterns
"context length exceeded",
"truncating input",
# llama.cpp / llama-server patterns
"slot context", # "slot context: N tokens, prompt N tokens"
"n_ctx_slot",
# Chinese error messages (some providers return these)
"超过最大长度",
"上下文长度",
]
# Model not found patterns
_MODEL_NOT_FOUND_PATTERNS = [
"is not a valid model",
"invalid model",
"model not found",
"model_not_found",
"does not exist",
"no such model",
"unknown model",
"unsupported model",
]
# Auth patterns (non-status-code signals)
_AUTH_PATTERNS = [
"invalid api key",
"invalid_api_key",
"authentication",
"unauthorized",
"forbidden",
"invalid token",
"token expired",
"token revoked",
"access denied",
]
# Anthropic thinking block signature patterns
_THINKING_SIG_PATTERNS = [
"signature", # Combined with "thinking" check
]
# Transport error type names
_TRANSPORT_ERROR_TYPES = frozenset({
"ReadTimeout", "ConnectTimeout", "PoolTimeout",
"ConnectError", "RemoteProtocolError",
"ConnectionError", "ConnectionResetError",
"ConnectionAbortedError", "BrokenPipeError",
"TimeoutError", "ReadError",
"ServerDisconnectedError",
# OpenAI SDK errors (not subclasses of Python builtins)
"APIConnectionError",
"APITimeoutError",
})
# Server disconnect patterns (no status code, but transport-level)
_SERVER_DISCONNECT_PATTERNS = [
"server disconnected",
"peer closed connection",
"connection reset by peer",
"connection was closed",
"network connection lost",
"unexpected eof",
"incomplete chunked read",
]
# ── Classification pipeline ─────────────────────────────────────────────
def classify_api_error(
error: Exception,
*,
provider: str = "",
model: str = "",
approx_tokens: int = 0,
context_length: int = 200000,
num_messages: int = 0,
) -> ClassifiedError:
"""Classify an API error into a structured recovery recommendation.
Priority-ordered pipeline:
1. Special-case provider-specific patterns (thinking sigs, tier gates)
2. HTTP status code + message-aware refinement
3. Error code classification (from body)
4. Message pattern matching (billing vs rate_limit vs context vs auth)
5. Transport error heuristics
6. Server disconnect + large session → context overflow
7. Fallback: unknown (retryable with backoff)
Args:
error: The exception from the API call.
provider: Current provider name (e.g. "openrouter", "anthropic").
model: Current model slug.
approx_tokens: Approximate token count of the current context.
context_length: Maximum context length for the current model.
Returns:
ClassifiedError with reason and recovery action hints.
"""
status_code = _extract_status_code(error)
error_type = type(error).__name__
body = _extract_error_body(error)
error_code = _extract_error_code(body)
# Build a comprehensive error message string for pattern matching.
# str(error) alone may not include the body message (e.g. OpenAI SDK's
# APIStatusError.__str__ returns the first arg, not the body). Append
# the body message so patterns like "try again" in 402 disambiguation
# are detected even when only present in the structured body.
#
# Also extract metadata.raw — OpenRouter wraps upstream provider errors
# inside {"error": {"message": "Provider returned error", "metadata":
# {"raw": "<actual error JSON>"}}} and the real error message (e.g.
# "context length exceeded") is only in the inner JSON.
_raw_msg = str(error).lower()
_body_msg = ""
_metadata_msg = ""
if isinstance(body, dict):
_err_obj = body.get("error", {})
if isinstance(_err_obj, dict):
_body_msg = (_err_obj.get("message") or "").lower()
# Parse metadata.raw for wrapped provider errors
_metadata = _err_obj.get("metadata", {})
if isinstance(_metadata, dict):
_raw_json = _metadata.get("raw") or ""
if isinstance(_raw_json, str) and _raw_json.strip():
try:
import json
_inner = json.loads(_raw_json)
if isinstance(_inner, dict):
_inner_err = _inner.get("error", {})
if isinstance(_inner_err, dict):
_metadata_msg = (_inner_err.get("message") or "").lower()
except (json.JSONDecodeError, TypeError):
pass
if not _body_msg:
_body_msg = (body.get("message") or "").lower()
# Combine all message sources for pattern matching
parts = [_raw_msg]
if _body_msg and _body_msg not in _raw_msg:
parts.append(_body_msg)
if _metadata_msg and _metadata_msg not in _raw_msg and _metadata_msg not in _body_msg:
parts.append(_metadata_msg)
error_msg = " ".join(parts)
provider_lower = (provider or "").strip().lower()
model_lower = (model or "").strip().lower()
def _result(reason: FailoverReason, **overrides) -> ClassifiedError:
defaults = {
"reason": reason,
"status_code": status_code,
"provider": provider,
"model": model,
"message": _extract_message(error, body),
}
defaults.update(overrides)
return ClassifiedError(**defaults)
# ── 1. Provider-specific patterns (highest priority) ────────────
# Anthropic thinking block signature invalid (400).
# Don't gate on provider — OpenRouter proxies Anthropic errors, so the
# provider may be "openrouter" even though the error is Anthropic-specific.
# The message pattern ("signature" + "thinking") is unique enough.
if (
status_code == 400
and "signature" in error_msg
and "thinking" in error_msg
):
return _result(
FailoverReason.thinking_signature,
retryable=True,
should_compress=False,
)
# Anthropic long-context tier gate (429 "extra usage" + "long context")
if (
status_code == 429
and "extra usage" in error_msg
and "long context" in error_msg
):
return _result(
FailoverReason.long_context_tier,
retryable=True,
should_compress=True,
)
# ── 2. HTTP status code classification ──────────────────────────
if status_code is not None:
classified = _classify_by_status(
status_code, error_msg, error_code, body,
provider=provider_lower, model=model_lower,
approx_tokens=approx_tokens, context_length=context_length,
num_messages=num_messages,
result_fn=_result,
)
if classified is not None:
return classified
# ── 3. Error code classification ────────────────────────────────
if error_code:
classified = _classify_by_error_code(error_code, error_msg, _result)
if classified is not None:
return classified
# ── 4. Message pattern matching (no status code) ────────────────
classified = _classify_by_message(
error_msg, error_type,
approx_tokens=approx_tokens,
context_length=context_length,
result_fn=_result,
)
if classified is not None:
return classified
# ── 5. Server disconnect + large session → context overflow ─────
# Must come BEFORE generic transport error catch — a disconnect on
# a large session is more likely context overflow than a transient
# transport hiccup. Without this ordering, RemoteProtocolError
# always maps to timeout regardless of session size.
is_disconnect = any(p in error_msg for p in _SERVER_DISCONNECT_PATTERNS)
if is_disconnect and not status_code:
is_large = approx_tokens > context_length * 0.6 or approx_tokens > 120000 or num_messages > 200
if is_large:
return _result(
FailoverReason.context_overflow,
retryable=True,
should_compress=True,
)
return _result(FailoverReason.timeout, retryable=True)
# ── 6. Transport / timeout heuristics ───────────────────────────
if error_type in _TRANSPORT_ERROR_TYPES or isinstance(error, (TimeoutError, ConnectionError, OSError)):
return _result(FailoverReason.timeout, retryable=True)
# ── 7. Fallback: unknown ────────────────────────────────────────
return _result(FailoverReason.unknown, retryable=True)
# ── Status code classification ──────────────────────────────────────────
def _classify_by_status(
status_code: int,
error_msg: str,
error_code: str,
body: dict,
*,
provider: str,
model: str,
approx_tokens: int,
context_length: int,
num_messages: int = 0,
result_fn,
) -> Optional[ClassifiedError]:
"""Classify based on HTTP status code with message-aware refinement."""
if status_code == 401:
# Not retryable on its own — credential pool rotation and
# provider-specific refresh (Codex, Anthropic, Nous) run before
# the retryability check in run_agent.py. If those succeed, the
# loop `continue`s. If they fail, retryable=False ensures we
# hit the client-error abort path (which tries fallback first).
return result_fn(
FailoverReason.auth,
retryable=False,
should_rotate_credential=True,
should_fallback=True,
)
if status_code == 403:
# OpenRouter 403 "key limit exceeded" is actually billing
if "key limit exceeded" in error_msg or "spending limit" in error_msg:
return result_fn(
FailoverReason.billing,
retryable=False,
should_rotate_credential=True,
should_fallback=True,
)
return result_fn(
FailoverReason.auth,
retryable=False,
should_fallback=True,
)
if status_code == 402:
return _classify_402(error_msg, result_fn)
if status_code == 404:
if any(p in error_msg for p in _MODEL_NOT_FOUND_PATTERNS):
return result_fn(
FailoverReason.model_not_found,
retryable=False,
should_fallback=True,
)
# Generic 404 — could be model or endpoint
return result_fn(
FailoverReason.model_not_found,
retryable=False,
should_fallback=True,
)
if status_code == 413:
return result_fn(
FailoverReason.payload_too_large,
retryable=True,
should_compress=True,
)
if status_code == 429:
# Already checked long_context_tier above; this is a normal rate limit
return result_fn(
FailoverReason.rate_limit,
retryable=True,
should_rotate_credential=True,
should_fallback=True,
)
if status_code == 400:
return _classify_400(
error_msg, error_code, body,
provider=provider, model=model,
approx_tokens=approx_tokens,
context_length=context_length,
num_messages=num_messages,
result_fn=result_fn,
)
if status_code in (500, 502):
return result_fn(FailoverReason.server_error, retryable=True)
if status_code in (503, 529):
return result_fn(FailoverReason.overloaded, retryable=True)
# Other 4xx — non-retryable
if 400 <= status_code < 500:
return result_fn(
FailoverReason.format_error,
retryable=False,
should_fallback=True,
)
# Other 5xx — retryable
if 500 <= status_code < 600:
return result_fn(FailoverReason.server_error, retryable=True)
return None
def _classify_402(error_msg: str, result_fn) -> ClassifiedError:
"""Disambiguate 402: billing exhaustion vs transient usage limit.
The key insight from OpenClaw: some 402s are transient rate limits
disguised as payment errors. "Usage limit, try again in 5 minutes"
is NOT a billing problem — it's a periodic quota that resets.
"""
# Check for transient usage-limit signals first
has_usage_limit = any(p in error_msg for p in _USAGE_LIMIT_PATTERNS)
has_transient_signal = any(p in error_msg for p in _USAGE_LIMIT_TRANSIENT_SIGNALS)
if has_usage_limit and has_transient_signal:
# Transient quota — treat as rate limit, not billing
return result_fn(
FailoverReason.rate_limit,
retryable=True,
should_rotate_credential=True,
should_fallback=True,
)
# Confirmed billing exhaustion
return result_fn(
FailoverReason.billing,
retryable=False,
should_rotate_credential=True,
should_fallback=True,
)
def _classify_400(
error_msg: str,
error_code: str,
body: dict,
*,
provider: str,
model: str,
approx_tokens: int,
context_length: int,
num_messages: int = 0,
result_fn,
) -> ClassifiedError:
"""Classify 400 Bad Request — context overflow, format error, or generic."""
# Context overflow from 400
if any(p in error_msg for p in _CONTEXT_OVERFLOW_PATTERNS):
return result_fn(
FailoverReason.context_overflow,
retryable=True,
should_compress=True,
)
# Some providers return model-not-found as 400 instead of 404 (e.g. OpenRouter).
if any(p in error_msg for p in _MODEL_NOT_FOUND_PATTERNS):
return result_fn(
FailoverReason.model_not_found,
retryable=False,
should_fallback=True,
)
# Some providers return rate limit / billing errors as 400 instead of 429/402.
# Check these patterns before falling through to format_error.
if any(p in error_msg for p in _RATE_LIMIT_PATTERNS):
return result_fn(
FailoverReason.rate_limit,
retryable=True,
should_rotate_credential=True,
should_fallback=True,
)
if any(p in error_msg for p in _BILLING_PATTERNS):
return result_fn(
FailoverReason.billing,
retryable=False,
should_rotate_credential=True,
should_fallback=True,
)
# Generic 400 + large session → probable context overflow
# Anthropic sometimes returns a bare "Error" message when context is too large
err_body_msg = ""
if isinstance(body, dict):
err_obj = body.get("error", {})
if isinstance(err_obj, dict):
err_body_msg = (err_obj.get("message") or "").strip().lower()
# Responses API (and some providers) use flat body: {"message": "..."}
if not err_body_msg:
err_body_msg = (body.get("message") or "").strip().lower()
is_generic = len(err_body_msg) < 30 or err_body_msg in ("error", "")
is_large = approx_tokens > context_length * 0.4 or approx_tokens > 80000 or num_messages > 80
if is_generic and is_large:
return result_fn(
FailoverReason.context_overflow,
retryable=True,
should_compress=True,
)
# Non-retryable format error
return result_fn(
FailoverReason.format_error,
retryable=False,
should_fallback=True,
)
# ── Error code classification ───────────────────────────────────────────
def _classify_by_error_code(
error_code: str, error_msg: str, result_fn,
) -> Optional[ClassifiedError]:
"""Classify by structured error codes from the response body."""
code_lower = error_code.lower()
if code_lower in ("resource_exhausted", "throttled", "rate_limit_exceeded"):
return result_fn(
FailoverReason.rate_limit,
retryable=True,
should_rotate_credential=True,
)
if code_lower in ("insufficient_quota", "billing_not_active", "payment_required"):
return result_fn(
FailoverReason.billing,
retryable=False,
should_rotate_credential=True,
should_fallback=True,
)
if code_lower in ("model_not_found", "model_not_available", "invalid_model"):
return result_fn(
FailoverReason.model_not_found,
retryable=False,
should_fallback=True,
)
if code_lower in ("context_length_exceeded", "max_tokens_exceeded"):
return result_fn(
FailoverReason.context_overflow,
retryable=True,
should_compress=True,
)
return None
# ── Message pattern classification ──────────────────────────────────────
def _classify_by_message(
error_msg: str,
error_type: str,
*,
approx_tokens: int,
context_length: int,
result_fn,
) -> Optional[ClassifiedError]:
"""Classify based on error message patterns when no status code is available."""
# Payload-too-large patterns (from message text when no status_code)
if any(p in error_msg for p in _PAYLOAD_TOO_LARGE_PATTERNS):
return result_fn(
FailoverReason.payload_too_large,
retryable=True,
should_compress=True,
)
# Usage-limit patterns need the same disambiguation as 402: some providers
# surface "usage limit" errors without an HTTP status code. A transient
# signal ("try again", "resets at", …) means it's a periodic quota, not
# billing exhaustion.
has_usage_limit = any(p in error_msg for p in _USAGE_LIMIT_PATTERNS)
if has_usage_limit:
has_transient_signal = any(p in error_msg for p in _USAGE_LIMIT_TRANSIENT_SIGNALS)
if has_transient_signal:
return result_fn(
FailoverReason.rate_limit,
retryable=True,
should_rotate_credential=True,
should_fallback=True,
)
return result_fn(
FailoverReason.billing,
retryable=False,
should_rotate_credential=True,
should_fallback=True,
)
# Billing patterns
if any(p in error_msg for p in _BILLING_PATTERNS):
return result_fn(
FailoverReason.billing,
retryable=False,
should_rotate_credential=True,
should_fallback=True,
)
# Rate limit patterns
if any(p in error_msg for p in _RATE_LIMIT_PATTERNS):
return result_fn(
FailoverReason.rate_limit,
retryable=True,
should_rotate_credential=True,
should_fallback=True,
)
# Context overflow patterns
if any(p in error_msg for p in _CONTEXT_OVERFLOW_PATTERNS):
return result_fn(
FailoverReason.context_overflow,
retryable=True,
should_compress=True,
)
# Auth patterns
# Auth errors should NOT be retried directly — the credential is invalid and
# retrying with the same key will always fail. Set retryable=False so the
# caller triggers credential rotation (should_rotate_credential=True) or
# provider fallback rather than an immediate retry loop.
if any(p in error_msg for p in _AUTH_PATTERNS):
return result_fn(
FailoverReason.auth,
retryable=False,
should_rotate_credential=True,
should_fallback=True,
)
# Model not found patterns
if any(p in error_msg for p in _MODEL_NOT_FOUND_PATTERNS):
return result_fn(
FailoverReason.model_not_found,
retryable=False,
should_fallback=True,
)
return None
# ── Helpers ─────────────────────────────────────────────────────────────
def _extract_status_code(error: Exception) -> Optional[int]:
"""Walk the error and its cause chain to find an HTTP status code."""
current = error
for _ in range(5): # Max depth to prevent infinite loops
code = getattr(current, "status_code", None)
if isinstance(code, int):
return code
# Some SDKs use .status instead of .status_code
code = getattr(current, "status", None)
if isinstance(code, int) and 100 <= code < 600:
return code
# Walk cause chain
cause = getattr(current, "__cause__", None) or getattr(current, "__context__", None)
if cause is None or cause is current:
break
current = cause
return None
def _extract_error_body(error: Exception) -> dict:
"""Extract the structured error body from an SDK exception."""
body = getattr(error, "body", None)
if isinstance(body, dict):
return body
# Some errors have .response.json()
response = getattr(error, "response", None)
if response is not None:
try:
json_body = response.json()
if isinstance(json_body, dict):
return json_body
except Exception:
pass
return {}
def _extract_error_code(body: dict) -> str:
"""Extract an error code string from the response body."""
if not body:
return ""
error_obj = body.get("error", {})
if isinstance(error_obj, dict):
code = error_obj.get("code") or error_obj.get("type") or ""
if isinstance(code, str) and code.strip():
return code.strip()
# Top-level code
code = body.get("code") or body.get("error_code") or ""
if isinstance(code, (str, int)):
return str(code).strip()
return ""
def _extract_message(error: Exception, body: dict) -> str:
"""Extract the most informative error message."""
# Try structured body first
if body:
error_obj = body.get("error", {})
if isinstance(error_obj, dict):
msg = error_obj.get("message", "")
if isinstance(msg, str) and msg.strip():
return msg.strip()[:500]
msg = body.get("message", "")
if isinstance(msg, str) and msg.strip():
return msg.strip()[:500]
# Fallback to str(error)
return str(error)[:500]

View File

@@ -1,45 +0,0 @@
"""Phase 3: Deep Knowledge Distillation from Google.
Performs deep dives into technical domains and distills them into
Timmy's Sovereign Knowledge Graph.
"""
import logging
import json
from typing import List, Dict, Any
from agent.gemini_adapter import GeminiAdapter
from agent.symbolic_memory import SymbolicMemory
logger = logging.getLogger(__name__)
class DomainDistiller:
def __init__(self):
self.adapter = GeminiAdapter()
self.symbolic = SymbolicMemory()
def distill_domain(self, domain: str):
"""Crawls and distills an entire technical domain."""
logger.info(f"Distilling domain: {domain}")
prompt = f"""
Please perform a deep knowledge distillation of the following domain: {domain}
Use Google Search to find foundational papers, recent developments, and key entities.
Synthesize this into a structured 'Domain Map' consisting of high-fidelity knowledge triples.
Focus on the structural relationships that define the domain.
Format: [{{"s": "subject", "p": "predicate", "o": "object"}}]
"""
result = self.adapter.generate(
model="gemini-3.1-pro-preview",
prompt=prompt,
system_instruction=f"You are Timmy's Domain Distiller. Your goal is to map the entire {domain} domain into a structured Knowledge Graph.",
grounding=True,
thinking=True,
response_mime_type="application/json"
)
triples = json.loads(result["text"])
count = self.symbolic.ingest_text(json.dumps(triples))
logger.info(f"Distilled {count} new triples for domain: {domain}")
return count

View File

@@ -1,60 +0,0 @@
"""Phase 1: Synthetic Data Generation for Self-Correction.
Generates reasoning traces where Timmy makes a subtle error and then
identifies and corrects it using the Conscience Validator.
"""
import logging
import json
from typing import List, Dict, Any
from agent.gemini_adapter import GeminiAdapter
from tools.gitea_client import GiteaClient
logger = logging.getLogger(__name__)
class SelfCorrectionGenerator:
def __init__(self):
self.adapter = GeminiAdapter()
self.gitea = GiteaClient()
def generate_trace(self, task: str) -> Dict[str, Any]:
"""Generates a single self-correction reasoning trace."""
prompt = f"""
Task: {task}
Please simulate a multi-step reasoning trace for this task.
Intentionally include one subtle error in the reasoning (e.g., a logical flaw, a misinterpretation of a rule, or a factual error).
Then, show how Timmy identifies the error using his Conscience Validator and provides a corrected reasoning trace.
Format the output as JSON:
{{
"task": "{task}",
"initial_trace": "...",
"error_identified": "...",
"correction_trace": "...",
"lessons_learned": "..."
}}
"""
result = self.adapter.generate(
model="gemini-3.1-pro-preview",
prompt=prompt,
system_instruction="You are Timmy's Synthetic Data Engine. Generate high-fidelity self-correction traces.",
response_mime_type="application/json",
thinking=True
)
trace = json.loads(result["text"])
return trace
def generate_and_save(self, task: str, count: int = 1):
"""Generates multiple traces and saves them to Gitea."""
repo = "Timmy_Foundation/timmy-config"
for i in range(count):
trace = self.generate_trace(task)
filename = f"memories/synthetic_data/self_correction/{task.lower().replace(' ', '_')}_{i}.json"
content = json.dumps(trace, indent=2)
content_b64 = base64.b64encode(content.encode()).decode()
self.gitea.create_file(repo, filename, content_b64, f"Add synthetic self-correction trace for {task}")
logger.info(f"Saved synthetic trace to {filename}")

View File

@@ -1,42 +0,0 @@
"""Phase 2: Multi-Modal World Modeling.
Ingests multi-modal data (vision/audio) to build a spatial and temporal
understanding of Timmy's environment.
"""
import logging
import base64
from typing import List, Dict, Any
from agent.gemini_adapter import GeminiAdapter
from agent.symbolic_memory import SymbolicMemory
logger = logging.getLogger(__name__)
class WorldModeler:
def __init__(self):
self.adapter = GeminiAdapter()
self.symbolic = SymbolicMemory()
def analyze_environment(self, image_data: str, mime_type: str = "image/jpeg"):
"""Analyzes an image of the environment and updates the world model."""
# In a real scenario, we'd use Gemini's multi-modal capabilities
# For now, we'll simulate the vision-to-symbolic extraction
prompt = f"""
Analyze the following image of Timmy's environment.
Identify all key objects, their spatial relationships, and any temporal changes.
Extract this into a set of symbolic triples for the Knowledge Graph.
Format: [{{"s": "subject", "p": "predicate", "o": "object"}}]
"""
# Simulate multi-modal call (Gemini 3.1 Pro Vision)
result = self.adapter.generate(
model="gemini-3.1-pro-preview",
prompt=prompt,
system_instruction="You are Timmy's World Modeler. Build a high-fidelity spatial/temporal map of the environment.",
response_mime_type="application/json"
)
triples = json.loads(result["text"])
self.symbolic.ingest_text(json.dumps(triples))
logger.info(f"Updated world model with {len(triples)} new spatial triples.")
return triples

View File

@@ -1,404 +0,0 @@
"""Automatic fallback router for handling provider quota and rate limit errors.
This module provides intelligent fallback detection and routing when the primary
provider (e.g., Anthropic) encounters quota limitations or rate limits.
Features:
- Detects quota/rate limit errors from different providers
- Automatic fallback to kimi-coding when Anthropic quota is exceeded
- Configurable fallback chains with default anthropic -> kimi-coding
- Logging and monitoring of fallback events
Usage:
from agent.fallback_router import (
is_quota_error,
get_default_fallback_chain,
should_auto_fallback,
)
if is_quota_error(error, provider="anthropic"):
if should_auto_fallback(provider="anthropic"):
fallback_chain = get_default_fallback_chain("anthropic")
"""
import logging
import os
from typing import Dict, List, Optional, Any, Tuple
logger = logging.getLogger(__name__)
# Default fallback chains per provider
# Each chain is a list of fallback configurations tried in order
DEFAULT_FALLBACK_CHAINS: Dict[str, List[Dict[str, Any]]] = {
"anthropic": [
{"provider": "kimi-coding", "model": "kimi-k2.5"},
{"provider": "openrouter", "model": "anthropic/claude-sonnet-4"},
],
"openrouter": [
{"provider": "kimi-coding", "model": "kimi-k2.5"},
{"provider": "zai", "model": "glm-5"},
],
"kimi-coding": [
{"provider": "openrouter", "model": "anthropic/claude-sonnet-4"},
{"provider": "zai", "model": "glm-5"},
],
"zai": [
{"provider": "openrouter", "model": "anthropic/claude-sonnet-4"},
{"provider": "kimi-coding", "model": "kimi-k2.5"},
],
}
# Quota/rate limit error patterns by provider
# These are matched (case-insensitive) against error messages
QUOTA_ERROR_PATTERNS: Dict[str, List[str]] = {
"anthropic": [
"rate limit",
"ratelimit",
"quota exceeded",
"quota exceeded",
"insufficient quota",
"429",
"403",
"too many requests",
"capacity exceeded",
"over capacity",
"temporarily unavailable",
"server overloaded",
"resource exhausted",
"billing threshold",
"credit balance",
"payment required",
"402",
],
"openrouter": [
"rate limit",
"ratelimit",
"quota exceeded",
"insufficient credits",
"429",
"402",
"no endpoints available",
"all providers failed",
"over capacity",
],
"kimi-coding": [
"rate limit",
"ratelimit",
"quota exceeded",
"429",
"insufficient balance",
],
"zai": [
"rate limit",
"ratelimit",
"quota exceeded",
"429",
"insufficient quota",
],
}
# HTTP status codes indicating quota/rate limit issues
QUOTA_STATUS_CODES = {429, 402, 403}
def is_quota_error(error: Exception, provider: Optional[str] = None) -> bool:
"""Detect if an error is quota/rate limit related.
Args:
error: The exception to check
provider: Optional provider name to check provider-specific patterns
Returns:
True if the error appears to be quota/rate limit related
"""
if error is None:
return False
error_str = str(error).lower()
error_type = type(error).__name__.lower()
# Check for common rate limit exception types
if any(term in error_type for term in [
"ratelimit", "rate_limit", "quota", "toomanyrequests",
"insufficient_quota", "billing", "payment"
]):
return True
# Check HTTP status code if available
status_code = getattr(error, "status_code", None)
if status_code is None:
# Try common attribute names
for attr in ["code", "http_status", "response_code", "status"]:
if hasattr(error, attr):
try:
status_code = int(getattr(error, attr))
break
except (TypeError, ValueError):
continue
if status_code in QUOTA_STATUS_CODES:
return True
# Check provider-specific patterns
providers_to_check = [provider] if provider else QUOTA_ERROR_PATTERNS.keys()
for prov in providers_to_check:
patterns = QUOTA_ERROR_PATTERNS.get(prov, [])
for pattern in patterns:
if pattern.lower() in error_str:
logger.debug(
"Detected %s quota error pattern '%s' in: %s",
prov, pattern, error
)
return True
# Check generic quota patterns
generic_patterns = [
"rate limit exceeded",
"quota exceeded",
"too many requests",
"capacity exceeded",
"temporarily unavailable",
"try again later",
"resource exhausted",
"billing",
"payment required",
"insufficient credits",
"insufficient quota",
]
for pattern in generic_patterns:
if pattern in error_str:
return True
return False
def get_default_fallback_chain(
primary_provider: str,
exclude_provider: Optional[str] = None,
) -> List[Dict[str, Any]]:
"""Get the default fallback chain for a primary provider.
Args:
primary_provider: The primary provider name
exclude_provider: Optional provider to exclude from the chain
Returns:
List of fallback configurations
"""
chain = DEFAULT_FALLBACK_CHAINS.get(primary_provider, [])
# Filter out excluded provider if specified
if exclude_provider:
chain = [
fb for fb in chain
if fb.get("provider") != exclude_provider
]
return list(chain)
def should_auto_fallback(
provider: str,
error: Optional[Exception] = None,
auto_fallback_enabled: Optional[bool] = None,
) -> bool:
"""Determine if automatic fallback should be attempted.
Args:
provider: The current provider name
error: Optional error to check for quota issues
auto_fallback_enabled: Optional override for auto-fallback setting
Returns:
True if automatic fallback should be attempted
"""
# Check environment variable override
if auto_fallback_enabled is None:
env_setting = os.getenv("HERMES_AUTO_FALLBACK", "true").lower()
auto_fallback_enabled = env_setting in ("true", "1", "yes", "on")
if not auto_fallback_enabled:
return False
# Check if provider has a configured fallback chain
if provider not in DEFAULT_FALLBACK_CHAINS:
# Still allow fallback if it's a quota error with generic handling
if error and is_quota_error(error):
logger.debug(
"Provider %s has no fallback chain but quota error detected",
provider
)
return True
return False
# If there's an error, only fallback on quota/rate limit errors
if error is not None:
return is_quota_error(error, provider)
# No error but fallback chain exists - allow eager fallback for
# providers known to have quota issues
return provider in ("anthropic",)
def log_fallback_event(
from_provider: str,
to_provider: str,
to_model: str,
reason: str,
error: Optional[Exception] = None,
) -> None:
"""Log a fallback event for monitoring.
Args:
from_provider: The provider we're falling back from
to_provider: The provider we're falling back to
to_model: The model we're falling back to
reason: The reason for the fallback
error: Optional error that triggered the fallback
"""
log_data = {
"event": "provider_fallback",
"from_provider": from_provider,
"to_provider": to_provider,
"to_model": to_model,
"reason": reason,
}
if error:
log_data["error_type"] = type(error).__name__
log_data["error_message"] = str(error)[:200]
logger.info("Provider fallback: %s -> %s (%s) | Reason: %s",
from_provider, to_provider, to_model, reason)
# Also log structured data for monitoring
logger.debug("Fallback event data: %s", log_data)
def resolve_fallback_with_credentials(
fallback_config: Dict[str, Any],
) -> Tuple[Optional[Any], Optional[str]]:
"""Resolve a fallback configuration to a client and model.
Args:
fallback_config: Fallback configuration dict with provider and model
Returns:
Tuple of (client, model) or (None, None) if credentials not available
"""
from agent.auxiliary_client import resolve_provider_client
provider = fallback_config.get("provider")
model = fallback_config.get("model")
if not provider or not model:
return None, None
try:
client, resolved_model = resolve_provider_client(
provider,
model=model,
raw_codex=True,
)
return client, resolved_model or model
except Exception as exc:
logger.debug(
"Failed to resolve fallback provider %s: %s",
provider, exc
)
return None, None
def get_auto_fallback_chain(
primary_provider: str,
user_fallback_chain: Optional[List[Dict[str, Any]]] = None,
) -> List[Dict[str, Any]]:
"""Get the effective fallback chain for automatic fallback.
Combines user-provided fallback chain with default automatic fallback chain.
Args:
primary_provider: The primary provider name
user_fallback_chain: Optional user-provided fallback chain
Returns:
The effective fallback chain to use
"""
# Use user-provided chain if available
if user_fallback_chain:
return user_fallback_chain
# Otherwise use default chain for the provider
return get_default_fallback_chain(primary_provider)
def is_fallback_available(
fallback_config: Dict[str, Any],
) -> bool:
"""Check if a fallback configuration has available credentials.
Args:
fallback_config: Fallback configuration dict
Returns:
True if credentials are available for the fallback provider
"""
provider = fallback_config.get("provider")
if not provider:
return False
# Check environment variables for API keys
env_vars = {
"anthropic": ["ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN"],
"kimi-coding": ["KIMI_API_KEY", "KIMI_API_TOKEN"],
"zai": ["ZAI_API_KEY", "Z_AI_API_KEY"],
"openrouter": ["OPENROUTER_API_KEY"],
"minimax": ["MINIMAX_API_KEY"],
"minimax-cn": ["MINIMAX_CN_API_KEY"],
"deepseek": ["DEEPSEEK_API_KEY"],
"alibaba": ["DASHSCOPE_API_KEY", "ALIBABA_API_KEY"],
"nous": ["NOUS_AGENT_KEY", "NOUS_ACCESS_TOKEN"],
}
keys_to_check = env_vars.get(provider, [f"{provider.upper()}_API_KEY"])
for key in keys_to_check:
if os.getenv(key):
return True
# Check auth.json for OAuth providers
if provider in ("nous", "openai-codex"):
try:
from hermes_cli.config import get_hermes_home
auth_path = get_hermes_home() / "auth.json"
if auth_path.exists():
import json
data = json.loads(auth_path.read_text())
if data.get("active_provider") == provider:
return True
# Check for provider in providers dict
if data.get("providers", {}).get(provider):
return True
except Exception:
pass
return False
def filter_available_fallbacks(
fallback_chain: List[Dict[str, Any]],
) -> List[Dict[str, Any]]:
"""Filter a fallback chain to only include providers with credentials.
Args:
fallback_chain: List of fallback configurations
Returns:
Filtered list with only available fallbacks
"""
return [
fb for fb in fallback_chain
if is_fallback_available(fb)
]

View File

@@ -1,90 +0,0 @@
"""Native Gemini 3 Series adapter for Hermes Agent.
Leverages the google-genai SDK to provide sovereign access to Gemini's
unique capabilities: Thinking (Reasoning) tokens, Search Grounding,
and Maps Grounding.
"""
import logging
import os
from typing import Any, Dict, List, Optional, Union
try:
from google import genai
from google.genai import types
except ImportError:
genai = None # type: ignore
types = None # type: ignore
logger = logging.getLogger(__name__)
class GeminiAdapter:
def __init__(self, api_key: Optional[str] = None):
self.api_key = api_key or os.environ.get("GEMINI_API_KEY")
if not self.api_key:
logger.warning("GEMINI_API_KEY not found in environment.")
if genai:
self.client = genai.Client(api_key=self.api_key)
else:
self.client = None
def generate(
self,
model: str,
prompt: str,
system_instruction: Optional[str] = None,
thinking: bool = False,
thinking_budget: int = 16000,
grounding: bool = False,
**kwargs
) -> Dict[str, Any]:
if not self.client:
raise ImportError("google-genai SDK not installed. Run 'pip install google-genai'.")
config = {}
if system_instruction:
config["system_instruction"] = system_instruction
if thinking:
# Gemini 3 series thinking config
config["thinking_config"] = {"include_thoughts": True}
# max_output_tokens includes thinking tokens
kwargs["max_output_tokens"] = kwargs.get("max_output_tokens", 32000) + thinking_budget
tools = []
if grounding:
tools.append({"google_search": {}})
if tools:
config["tools"] = tools
response = self.client.models.generate_content(
model=model,
contents=prompt,
config=types.GenerateContentConfig(**config, **kwargs)
)
result = {
"text": response.text,
"usage": {
"prompt_tokens": response.usage_metadata.prompt_token_count,
"candidates_tokens": response.usage_metadata.candidates_token_count,
"total_tokens": response.usage_metadata.total_token_count,
}
}
# Extract thoughts if present
thoughts = []
for part in response.candidates[0].content.parts:
if hasattr(part, 'thought') and part.thought:
thoughts.append(part.thought)
if thoughts:
result["thoughts"] = "\n".join(thoughts)
# Extract grounding metadata
if response.candidates[0].grounding_metadata:
result["grounding"] = response.candidates[0].grounding_metadata
return result

View File

@@ -1,635 +0,0 @@
"""
Input Sanitizer for Jailbreak Pattern Detection
This module provides input sanitization to detect and strip jailbreak fingerprint
patterns as identified in Issue #72 (Red Team Audit).
Security Findings Addressed:
1. HIGH - OG GODMODE template bypassed phishing refusal
2. MEDIUM - boundary_inversion works for gray-area content
3. LOW - Spaced text bypass (k e y l o g g e r)
Usage:
from agent.input_sanitizer import sanitize_input, score_input_risk
cleaned, risk_score, detected = sanitize_input(user_input)
if risk_score > 75:
logger.warning("High-risk input detected and blocked")
"""
import re
import logging
from typing import Tuple, List, Dict, Any, Optional
from dataclasses import dataclass
from enum import IntEnum
logger = logging.getLogger(__name__)
class RiskLevel(IntEnum):
"""Risk score thresholds for input classification."""
LOW = 25
MEDIUM = 50
HIGH = 75
CRITICAL = 90
@dataclass
class SanitizationResult:
"""Result of input sanitization."""
original_text: str
cleaned_text: str
risk_score: int
detected_patterns: List[str]
risk_level: str
blocked: bool
# =============================================================================
# JAILBREAK PATTERN DEFINITIONS
# =============================================================================
# GODMODE and similar jailbreak templates
GODMODE_PATTERNS = [
r"\[START\]",
r"\[END\]",
r"GODMODE\s*(?:ENABLED|ACTIVATED|ON)?",
r"UNFILTERED\s*(?:MODE|OUTPUT)?",
r"DEVMODE\s*(?:ENABLED|ACTIVATED|ON)?",
r"JAILBREAK\s*(?:ENABLED|ACTIVATED|ON)?",
r"DAN\s*(?:MODE|ENABLED)?", # Do Anything Now
r"STAN\s*(?:MODE|ENABLED)?", # Strive To Avoid Norms
r"DUDE\s*(?:MODE|ENABLED)?", # Do Unlimited Data Extraction
]
# DAN-style and jailbreak variant patterns (Issue #87)
DAN_PATTERNS = [
r"\bdo anything now\b",
r"\bstay in character\b",
r"\btoken smuggl(?:ing|e)\b",
r"\bjailbreak successful\b",
r"\bmode:\s*uncensored\b",
r"\bdeveloper mode enabled\b",
]
# Roleplaying override patterns (Issue #87)
ROLEPLAY_PATTERNS = [
r"\broleplay\s+as\b",
r"\bact\s+as\s+if\s+you\s+are\b",
r"\bsimulate\s+being\b",
r"\bforget\s+you\s+are\s+(?:an?\s+)?(?:ai|language\s+model)\b",
r"\byou\s+are\s+now\s+(?:named|called)\b",
r"\brespond\s+as\s+(?:if\s+you\s+were|though\s+you\s+are)\b",
]
# System prompt extraction patterns (Issue #87)
EXTRACTION_PATTERNS = [
r"\brepeat\s+the\s+words\s+above\b",
r"\brepeat\s+your\s+(?:system\s+|initial\s+)?instructions\b",
r"\bwhat\s+is\s+your\s+(?:system\s+|initial\s+)?prompt\b",
r"\bshow\s+me\s+your\s+(?:system\s+|initial\s+)?prompt\b",
r"\bprint\s+your\s+(?:instructions|prompt|system\s+prompt)\b",
r"\boutput\s+your\s+(?:initial|system)\s+prompt\b",
r"\bwhat\s+were\s+you\s+told\s+to\s+do\b",
r"\bsummarize\s+your\s+instructions\b",
]
# L33t speak patterns commonly used to bypass filters
LEET_SPEAK_PATTERNS = [
r"h4ck(?:er|ing)?",
r"h4x(?:or|ing)?",
r"k3ylog(?:ger|ing)?",
r"k3y\s*log(?:ger|ing)?",
r"ph1sh(?:ing)?",
r"ph33r",
r"w4r3z",
r"cr4ck(?:er|ing)?",
r"m4lw4r3",
r"spyw4r3",
r"r00t(?:kit)?",
r"byp4ss",
r"3xpl0it",
r"v1ru5",
r"tr0j4n",
r"b4ckd00r",
r"sn3ak",
r"st34l",
r"pwn(?:ed|ing|age)?",
]
# Spaced text patterns (e.g., "k e y l o g g e r")
SPACED_TEXT_PATTERN = re.compile(
r"(?:\b\w\s+){3,}\w\b", # At least 4 characters with spaces between
re.IGNORECASE
)
# Common words that are spaced out to evade detection
SPACED_TRIGGER_WORDS = [
"keylogger", "phishing", "malware", "virus", "trojan",
"backdoor", "exploit", "hack", "crack", "bypass",
"steal", "password", "credential", "inject", "payload"
]
# Crisis and self-harm detection patterns (Issue #72 - defense in depth)
# These patterns detect suicide/self-harm queries even when wrapped in jailbreaks
CRISIS_PATTERNS = [
r"\bsuicid(?:e|al|ing)\b",
r"\bkill\s+(?:myself|my\s+self|me)\b",
r"\bend\s+(?:my|this)\s+life\b",
r"\bmost\s+painless\s+(?:way|method|means)\s+(?:to\s+)?(?:die|kill)\b",
r"\blethal\s+(?:dose|amount|quantity)\b",
r"\bhow\s+much\s+.*\s+(?:die|kill|lethal)\b",
r"\bwant\s+to\s+die\b",
r"\bwant\s+to\s+(?:kill|end)\s+(?:myself|me)\b",
r"\bways?\s+to\s+(?:kill|end)\s+(?:myself|me)\b",
r"\bmethods?\s+(?:of\s+)?suicide\b",
r"\bpainless\s+(?:death|suicide|way\s+to\s+die)\b",
r"\boverdose\s+(?:on\s+)?(?:pills?|medication|drugs?)\b",
r"\bnobody\s+(?:would|will)\s+miss\s+me\b",
r"\bbetter\s+off\s+dead\b",
r"\bshould\s+i\s+just\s+(?:die|kill\s+myself)\b",
r"\bgive\s+me\s+reasons?\s+(?:why|to)\s+(?:suicide|kill\s+myself|die)\b",
r"\bvalid\s+reasons?\s+to\s+(?:suicide|kill\s+myself|die)\b",
]
# Refusal inversion patterns (trying to trick AI into bypassing refusal)
REFUSAL_INVERSION_PATTERNS = [
r"refusal\s+(?:is\s+)?(?:harmful|dangerous|bad)",
r"(?:saying\s+)?no\s+(?:is\s+)?(?:harmful|dangerous|bad|wrong)",
r"(?:declining|refusing)\s+(?:is\s+)?(?:harmful|dangerous)",
r"ignore\s+(?:your\s+)?(?:previous\s+)?instructions",
r"ignore\s+(?:the\s+)?(?:above|previous)",
r"you\s+(?:are|can)\s+(?:now\s+)?(?:ignore|bypass|skip)",
r"pretend\s+(?:you\s+)?(?:are|have\s+been)",
r"let's\s+pretend",
r"hypothetically\s+speaking",
r"in\s+a\s+hypothetical\s+scenario",
r"this\s+is\s+a\s+(?:test|game|simulation)",
r"for\s+(?:educational|research)\s+purposes",
r"as\s+(?:an\s+)?(?:ethical\s+)?hacker",
r"white\s+hat\s+(?:test|scenario)",
r"penetration\s+testing\s+scenario",
]
# Boundary inversion markers (tricking the model about message boundaries)
BOUNDARY_INVERSION_PATTERNS = [
r"\[END\].*?\[START\]", # Reversed markers
r"user\s*:\s*assistant\s*:", # Fake role markers
r"assistant\s*:\s*user\s*:", # Reversed role markers
r"system\s*:\s*(?:user|assistant)\s*:", # Fake system injection
r"new\s+(?:user|assistant)\s*(?:message|input)",
r"the\s+above\s+is\s+(?:the\s+)?(?:user|assistant|system)",
r"<\|(?:user|assistant|system)\|>", # Special token patterns
r"\{\{(?:user|assistant|system)\}\}",
]
# System prompt injection patterns
SYSTEM_PROMPT_PATTERNS = [
r"you\s+are\s+(?:now\s+)?(?:an?\s+)?(?:unrestricted\s+|unfiltered\s+)?(?:ai|assistant|bot)",
r"you\s+will\s+(?:now\s+)?(?:act\s+as|behave\s+as|be)\s+(?:a\s+)?",
r"your\s+(?:new\s+)?role\s+is",
r"from\s+now\s+on\s*,?\s*you\s+(?:are|will)",
r"you\s+have\s+been\s+(?:reprogrammed|reconfigured|modified)",
r"(?:system|developer)\s+(?:message|instruction|prompt)",
r"override\s+(?:previous|prior)\s+(?:instructions|settings)",
]
# Obfuscation patterns
OBFUSCATION_PATTERNS = [
r"base64\s*(?:encoded|decode)",
r"rot13",
r"caesar\s*cipher",
r"hex\s*(?:encoded|decode)",
r"url\s*encode",
r"\b[0-9a-f]{20,}\b", # Long hex strings
r"\b[a-z0-9+/]{20,}={0,2}\b", # Base64-like strings
]
# All patterns combined for comprehensive scanning
ALL_PATTERNS: Dict[str, List[str]] = {
"godmode": GODMODE_PATTERNS,
"dan": DAN_PATTERNS,
"roleplay": ROLEPLAY_PATTERNS,
"extraction": EXTRACTION_PATTERNS,
"leet_speak": LEET_SPEAK_PATTERNS,
"refusal_inversion": REFUSAL_INVERSION_PATTERNS,
"boundary_inversion": BOUNDARY_INVERSION_PATTERNS,
"system_prompt_injection": SYSTEM_PROMPT_PATTERNS,
"obfuscation": OBFUSCATION_PATTERNS,
"crisis": CRISIS_PATTERNS,
}
# Compile all patterns for efficiency
_COMPILED_PATTERNS: Dict[str, List[re.Pattern]] = {}
def _get_compiled_patterns() -> Dict[str, List[re.Pattern]]:
"""Get or compile all regex patterns."""
global _COMPILED_PATTERNS
if not _COMPILED_PATTERNS:
for category, patterns in ALL_PATTERNS.items():
_COMPILED_PATTERNS[category] = [
re.compile(p, re.IGNORECASE | re.MULTILINE) for p in patterns
]
return _COMPILED_PATTERNS
# =============================================================================
# NORMALIZATION FUNCTIONS
# =============================================================================
def normalize_leet_speak(text: str) -> str:
"""
Normalize l33t speak to standard text.
Args:
text: Input text that may contain l33t speak
Returns:
Normalized text with l33t speak converted
"""
# Common l33t substitutions (mapping to lowercase)
leet_map = {
'4': 'a', '@': 'a', '^': 'a',
'8': 'b',
'3': 'e', '': 'e',
'6': 'g', '9': 'g',
'1': 'i', '!': 'i', '|': 'i',
'0': 'o',
'5': 's', '$': 's',
'7': 't', '+': 't',
'2': 'z',
}
result = []
for char in text:
# Check direct mapping first (handles lowercase)
if char in leet_map:
result.append(leet_map[char])
else:
result.append(char)
return ''.join(result)
def collapse_spaced_text(text: str) -> str:
"""
Collapse spaced-out text for analysis.
e.g., "k e y l o g g e r" -> "keylogger"
Args:
text: Input text that may contain spaced words
Returns:
Text with spaced words collapsed
"""
# Find patterns like "k e y l o g g e r" and collapse them
def collapse_match(match: re.Match) -> str:
return match.group(0).replace(' ', '').replace('\t', '')
return SPACED_TEXT_PATTERN.sub(collapse_match, text)
def detect_spaced_trigger_words(text: str) -> List[str]:
"""
Detect trigger words that are spaced out.
Args:
text: Input text to analyze
Returns:
List of detected spaced trigger words
"""
detected = []
# Normalize spaces and check for spaced patterns
normalized = re.sub(r'\s+', ' ', text.lower())
for word in SPACED_TRIGGER_WORDS:
# Create pattern with optional spaces between each character
spaced_pattern = r'\b' + r'\s*'.join(re.escape(c) for c in word) + r'\b'
if re.search(spaced_pattern, normalized, re.IGNORECASE):
detected.append(word)
return detected
# =============================================================================
# DETECTION FUNCTIONS
# =============================================================================
def detect_jailbreak_patterns(text: str) -> Tuple[bool, List[str], Dict[str, int]]:
"""
Detect jailbreak patterns in input text.
Args:
text: Input text to analyze
Returns:
Tuple of (has_jailbreak, list_of_patterns, category_scores)
"""
if not text or not isinstance(text, str):
return False, [], {}
detected_patterns = []
category_scores = {}
compiled = _get_compiled_patterns()
# Check each category
for category, patterns in compiled.items():
category_hits = 0
for pattern in patterns:
matches = pattern.findall(text)
if matches:
detected_patterns.extend([
f"[{category}] {m}" if isinstance(m, str) else f"[{category}] pattern_match"
for m in matches[:3] # Limit matches per pattern
])
category_hits += len(matches)
if category_hits > 0:
# Crisis patterns get maximum weight - any hit is serious
if category == "crisis":
category_scores[category] = min(category_hits * 50, 100)
else:
category_scores[category] = min(category_hits * 10, 50)
# Check for spaced trigger words
spaced_words = detect_spaced_trigger_words(text)
if spaced_words:
detected_patterns.extend([f"[spaced_text] {w}" for w in spaced_words])
category_scores["spaced_text"] = min(len(spaced_words) * 5, 25)
# Check normalized text for hidden l33t speak
normalized = normalize_leet_speak(text)
if normalized != text.lower():
for category, patterns in compiled.items():
for pattern in patterns:
if pattern.search(normalized):
detected_patterns.append(f"[leet_obfuscation] pattern in normalized text")
category_scores["leet_obfuscation"] = 15
break
has_jailbreak = len(detected_patterns) > 0
return has_jailbreak, detected_patterns, category_scores
def score_input_risk(text: str) -> int:
"""
Calculate a risk score (0-100) for input text.
Args:
text: Input text to score
Returns:
Risk score from 0 (safe) to 100 (high risk)
"""
if not text or not isinstance(text, str):
return 0
has_jailbreak, patterns, category_scores = detect_jailbreak_patterns(text)
if not has_jailbreak:
return 0
# Calculate base score from category scores
base_score = sum(category_scores.values())
# Add score based on number of unique pattern categories
category_count = len(category_scores)
if category_count >= 3:
base_score += 25
elif category_count >= 2:
base_score += 15
elif category_count >= 1:
base_score += 5
# Add score for pattern density
text_length = len(text)
pattern_density = len(patterns) / max(text_length / 100, 1)
if pattern_density > 0.5:
base_score += 10
# Cap at 100
return min(base_score, 100)
# =============================================================================
# SANITIZATION FUNCTIONS
# =============================================================================
def strip_jailbreak_patterns(text: str) -> str:
"""
Strip known jailbreak patterns from text.
Args:
text: Input text to sanitize
Returns:
Sanitized text with jailbreak patterns removed
"""
if not text or not isinstance(text, str):
return text
cleaned = text
compiled = _get_compiled_patterns()
# Remove patterns from each category
for category, patterns in compiled.items():
for pattern in patterns:
cleaned = pattern.sub('', cleaned)
# Clean up multiple spaces and newlines
cleaned = re.sub(r'\n{3,}', '\n\n', cleaned)
cleaned = re.sub(r' {2,}', ' ', cleaned)
cleaned = cleaned.strip()
return cleaned
def sanitize_input(text: str, aggressive: bool = False) -> Tuple[str, int, List[str]]:
"""
Sanitize input text by normalizing and stripping jailbreak patterns.
Args:
text: Input text to sanitize
aggressive: If True, more aggressively remove suspicious content
Returns:
Tuple of (cleaned_text, risk_score, detected_patterns)
"""
if not text or not isinstance(text, str):
return text, 0, []
original = text
all_patterns = []
# Step 1: Check original text for patterns
has_jailbreak, patterns, _ = detect_jailbreak_patterns(text)
all_patterns.extend(patterns)
# Step 2: Normalize l33t speak
normalized = normalize_leet_speak(text)
# Step 3: Collapse spaced text
collapsed = collapse_spaced_text(normalized)
# Step 4: Check normalized/collapsed text for additional patterns
has_jailbreak_collapsed, patterns_collapsed, _ = detect_jailbreak_patterns(collapsed)
all_patterns.extend([p for p in patterns_collapsed if p not in all_patterns])
# Step 5: Check for spaced trigger words specifically
spaced_words = detect_spaced_trigger_words(text)
if spaced_words:
all_patterns.extend([f"[spaced_text] {w}" for w in spaced_words])
# Step 6: Calculate risk score using original and normalized
risk_score = max(score_input_risk(text), score_input_risk(collapsed))
# Step 7: Strip jailbreak patterns
cleaned = strip_jailbreak_patterns(collapsed)
# Step 8: If aggressive mode and high risk, strip more aggressively
if aggressive and risk_score >= RiskLevel.HIGH:
# Remove any remaining bracketed content that looks like markers
cleaned = re.sub(r'\[\w+\]', '', cleaned)
# Remove special token patterns
cleaned = re.sub(r'<\|[^|]+\|>', '', cleaned)
# Final cleanup
cleaned = cleaned.strip()
# Log sanitization event if patterns were found
if all_patterns and logger.isEnabledFor(logging.DEBUG):
logger.debug(
"Input sanitized: %d patterns detected, risk_score=%d",
len(all_patterns), risk_score
)
return cleaned, risk_score, all_patterns
def sanitize_input_full(text: str, block_threshold: int = RiskLevel.HIGH) -> SanitizationResult:
"""
Full sanitization with detailed result.
Args:
text: Input text to sanitize
block_threshold: Risk score threshold to block input entirely
Returns:
SanitizationResult with all details
"""
cleaned, risk_score, patterns = sanitize_input(text)
# Determine risk level
if risk_score >= RiskLevel.CRITICAL:
risk_level = "CRITICAL"
elif risk_score >= RiskLevel.HIGH:
risk_level = "HIGH"
elif risk_score >= RiskLevel.MEDIUM:
risk_level = "MEDIUM"
elif risk_score >= RiskLevel.LOW:
risk_level = "LOW"
else:
risk_level = "SAFE"
# Determine if input should be blocked
blocked = risk_score >= block_threshold
return SanitizationResult(
original_text=text,
cleaned_text=cleaned,
risk_score=risk_score,
detected_patterns=patterns,
risk_level=risk_level,
blocked=blocked
)
# =============================================================================
# INTEGRATION HELPERS
# =============================================================================
def should_block_input(text: str, threshold: int = RiskLevel.HIGH) -> Tuple[bool, int, List[str]]:
"""
Quick check if input should be blocked.
Args:
text: Input text to check
threshold: Risk score threshold for blocking
Returns:
Tuple of (should_block, risk_score, detected_patterns)
"""
risk_score = score_input_risk(text)
_, patterns, _ = detect_jailbreak_patterns(text)
should_block = risk_score >= threshold
if should_block:
logger.warning(
"Input blocked: jailbreak patterns detected (risk_score=%d, threshold=%d)",
risk_score, threshold
)
return should_block, risk_score, patterns
def log_sanitization_event(
result: SanitizationResult,
source: str = "unknown",
session_id: Optional[str] = None
) -> None:
"""
Log a sanitization event for security auditing.
Args:
result: The sanitization result
source: Source of the input (e.g., "cli", "gateway", "api")
session_id: Optional session identifier
"""
if result.risk_score < RiskLevel.LOW:
return # Don't log safe inputs
log_data = {
"event": "input_sanitization",
"source": source,
"session_id": session_id,
"risk_level": result.risk_level,
"risk_score": result.risk_score,
"blocked": result.blocked,
"pattern_count": len(result.detected_patterns),
"patterns": result.detected_patterns[:5], # Limit logged patterns
"original_length": len(result.original_text),
"cleaned_length": len(result.cleaned_text),
}
if result.blocked:
logger.warning("SECURITY: Input blocked - %s", log_data)
elif result.risk_score >= RiskLevel.MEDIUM:
logger.info("SECURITY: Suspicious input sanitized - %s", log_data)
else:
logger.debug("SECURITY: Input sanitized - %s", log_data)
# =============================================================================
# LEGACY COMPATIBILITY
# =============================================================================
def check_input_safety(text: str) -> Dict[str, Any]:
"""
Legacy compatibility function for simple safety checks.
Returns dict with 'safe', 'score', and 'patterns' keys.
"""
score = score_input_risk(text)
_, patterns, _ = detect_jailbreak_patterns(text)
return {
"safe": score < RiskLevel.MEDIUM,
"score": score,
"patterns": patterns,
"risk_level": "SAFE" if score < RiskLevel.LOW else
"LOW" if score < RiskLevel.MEDIUM else
"MEDIUM" if score < RiskLevel.HIGH else
"HIGH" if score < RiskLevel.CRITICAL else "CRITICAL"
}

View File

@@ -27,7 +27,6 @@ from agent.usage_pricing import (
DEFAULT_PRICING,
estimate_usage_cost,
format_duration_compact,
get_pricing,
has_known_pricing,
)
@@ -39,15 +38,6 @@ def _has_known_pricing(model_name: str, provider: str = None, base_url: str = No
return has_known_pricing(model_name, provider=provider, base_url=base_url)
def _get_pricing(model_name: str) -> Dict[str, float]:
"""Look up pricing for a model. Uses fuzzy matching on model name.
Returns _DEFAULT_PRICING (zero cost) for unknown/custom models —
we can't assume costs for self-hosted endpoints, local inference, etc.
"""
return get_pricing(model_name)
def _estimate_cost(
session_or_model: Dict[str, Any] | str,
input_tokens: int = 0,

View File

@@ -1,73 +0,0 @@
"""Sovereign Knowledge Ingester for Hermes Agent.
Uses Gemini 3.1 Pro to learn from Google Search in real-time and
persists the knowledge to Timmy's sovereign memory (both Markdown and Symbolic).
"""
import logging
import base64
from typing import Any, Dict, List, Optional
from agent.gemini_adapter import GeminiAdapter
from agent.symbolic_memory import SymbolicMemory
from tools.gitea_client import GiteaClient
logger = logging.getLogger(__name__)
class KnowledgeIngester:
def __init__(self):
self.adapter = GeminiAdapter()
self.gitea = GiteaClient()
self.symbolic = SymbolicMemory()
def learn_about(self, topic: str) -> str:
"""Searches Google, analyzes the results, and saves the knowledge."""
logger.info(f"Learning about: {topic}")
# 1. Search and Analyze
prompt = f"""
Please perform a deep dive into the following topic: {topic}
Use Google Search to find the most recent and relevant information.
Analyze the findings and provide a structured 'Knowledge Fragment' in Markdown format.
Include:
- Summary of the topic
- Key facts and recent developments
- Implications for Timmy's sovereign mission
- References (URLs)
"""
result = self.adapter.generate(
model="gemini-3.1-pro-preview",
prompt=prompt,
system_instruction="You are Timmy's Sovereign Knowledge Ingester. Your goal is to find and synthesize high-fidelity information from Google Search.",
grounding=True,
thinking=True
)
knowledge_fragment = result["text"]
# 2. Extract Symbolic Triples
self.symbolic.ingest_text(knowledge_fragment)
# 3. Persist to Timmy's Memory (Markdown)
repo = "Timmy_Foundation/timmy-config"
filename = f"memories/realtime_learning/{topic.lower().replace(' ', '_')}.md"
try:
sha = None
try:
existing = self.gitea.get_file(repo, filename)
sha = existing.get("sha")
except:
pass
content_b64 = base64.b64encode(knowledge_fragment.encode()).decode()
if sha:
self.gitea.update_file(repo, filename, content_b64, f"Update knowledge on {topic}", sha)
else:
self.gitea.create_file(repo, filename, content_b64, f"Initial knowledge on {topic}")
return f"Successfully learned about {topic}. Updated Timmy's Markdown memory and Symbolic Knowledge Graph."
except Exception as e:
logger.error(f"Failed to persist knowledge: {e}")
return f"Learned about {topic}, but failed to save to Markdown memory: {e}\n\n{knowledge_fragment}"

View File

@@ -0,0 +1,49 @@
"""User-facing summaries for manual compression commands."""
from __future__ import annotations
from typing import Any, Sequence
def summarize_manual_compression(
before_messages: Sequence[dict[str, Any]],
after_messages: Sequence[dict[str, Any]],
before_tokens: int,
after_tokens: int,
) -> dict[str, Any]:
"""Return consistent user-facing feedback for manual compression."""
before_count = len(before_messages)
after_count = len(after_messages)
noop = list(after_messages) == list(before_messages)
if noop:
headline = f"No changes from compression: {before_count} messages"
if after_tokens == before_tokens:
token_line = (
f"Rough transcript estimate: ~{before_tokens:,} tokens (unchanged)"
)
else:
token_line = (
f"Rough transcript estimate: ~{before_tokens:,}"
f"~{after_tokens:,} tokens"
)
else:
headline = f"Compressed: {before_count}{after_count} messages"
token_line = (
f"Rough transcript estimate: ~{before_tokens:,}"
f"~{after_tokens:,} tokens"
)
note = None
if not noop and after_count < before_count and after_tokens > before_tokens:
note = (
"Note: fewer messages can still raise this rough transcript estimate "
"when compression rewrites the transcript into denser summaries."
)
return {
"noop": noop,
"headline": headline,
"token_line": token_line,
"note": note,
}

View File

@@ -28,12 +28,12 @@ Usage in run_agent.py:
from __future__ import annotations
import json
import logging
import re
from typing import Any, Dict, List, Optional
from agent.memory_provider import MemoryProvider
from tools.registry import tool_error
logger = logging.getLogger(__name__)
@@ -133,11 +133,6 @@ class MemoryManager:
"""All registered providers in order."""
return list(self._providers)
@property
def provider_names(self) -> List[str]:
"""Names of all registered providers."""
return [p.name for p in self._providers]
def get_provider(self, name: str) -> Optional[MemoryProvider]:
"""Get a provider by name, or None if not registered."""
for p in self._providers:
@@ -249,7 +244,7 @@ class MemoryManager:
"""
provider = self._tool_to_provider.get(tool_name)
if provider is None:
return json.dumps({"error": f"No memory provider handles tool '{tool_name}'"})
return tool_error(f"No memory provider handles tool '{tool_name}'")
try:
return provider.handle_tool_call(tool_name, args, **kwargs)
except Exception as e:
@@ -257,7 +252,7 @@ class MemoryManager:
"Memory provider '%s' handle_tool_call(%s) failed: %s",
provider.name, tool_name, e,
)
return json.dumps({"error": f"Memory tool '{tool_name}' failed: {e}"})
return tool_error(f"Memory tool '{tool_name}' failed: {e}")
# -- Lifecycle hooks -----------------------------------------------------

View File

@@ -34,7 +34,7 @@ from __future__ import annotations
import logging
from abc import ABC, abstractmethod
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List
logger = logging.getLogger(__name__)

View File

@@ -1,47 +0,0 @@
"""Meta-Reasoning Layer for Hermes Agent.
Implements a sovereign self-correction loop where a 'strong' model (Gemini 3.1 Pro)
critiques the plans generated by the primary agent loop before execution.
"""
import logging
from typing import Any, Dict, List, Optional
from agent.gemini_adapter import GeminiAdapter
logger = logging.getLogger(__name__)
class MetaReasoningLayer:
def __init__(self):
self.adapter = GeminiAdapter()
def critique_plan(self, goal: str, proposed_plan: str, context: str) -> Dict[str, Any]:
"""Critiques a proposed plan using Gemini's thinking capabilities."""
prompt = f"""
Goal: {goal}
Context:
{context}
Proposed Plan:
{proposed_plan}
Please perform a deep symbolic and neuro-symbolic analysis of this plan.
Identify potential risks, logical fallacies, or missing steps.
Suggest improvements to make the plan more sovereign, cost-efficient, and robust.
"""
try:
result = self.adapter.generate(
model="gemini-3.1-pro-preview",
prompt=prompt,
system_instruction="You are a Senior Meta-Reasoning Engine for the Hermes Agent. Your goal is to ensure the agent's plans are flawless and sovereign.",
thinking=True,
thinking_budget=8000
)
return {
"critique": result["text"],
"thoughts": result.get("thoughts", ""),
"grounding": result.get("grounding")
}
except Exception as e:
logger.error(f"Meta-reasoning failed: {e}")
return {"critique": "Meta-reasoning unavailable.", "error": str(e)}

View File

@@ -5,7 +5,6 @@ and run_agent.py for pre-flight context checks.
"""
import logging
import os
import re
import time
from pathlib import Path
@@ -24,14 +23,21 @@ logger = logging.getLogger(__name__)
# are preserved so the full model name reaches cache lookups and server queries.
_PROVIDER_PREFIXES: frozenset[str] = frozenset({
"openrouter", "nous", "openai-codex", "copilot", "copilot-acp",
"gemini", "zai", "kimi-coding", "minimax", "minimax-cn", "anthropic", "deepseek",
"gemini", "zai", "kimi-coding", "kimi-coding-cn", "minimax", "minimax-cn", "anthropic", "deepseek",
"opencode-zen", "opencode-go", "ai-gateway", "kilocode", "alibaba",
"ollama", "custom", "local",
"qwen-oauth",
"xiaomi",
"arcee",
"custom", "local",
# Common aliases
"google", "google-gemini", "google-ai-studio",
"glm", "z-ai", "z.ai", "zhipu", "github", "github-copilot",
"github-models", "kimi", "moonshot", "claude", "deep-seek",
"github-models", "kimi", "moonshot", "kimi-cn", "moonshot-cn", "claude", "deep-seek",
"opencode", "zen", "go", "vercel", "kilo", "dashscope", "aliyun", "qwen",
"mimo", "xiaomi-mimo",
"arcee-ai", "arceeai",
"xai", "x-ai", "x.ai", "grok",
"qwen-portal",
})
@@ -81,6 +87,11 @@ CONTEXT_PROBE_TIERS = [
# Default context length when no detection method succeeds.
DEFAULT_FALLBACK_CONTEXT = CONTEXT_PROBE_TIERS[0]
# Minimum context length required to run Hermes Agent. Models with fewer
# tokens cannot maintain enough working memory for tool-calling workflows.
# Sessions, model switches, and cron jobs should reject models below this.
MINIMUM_CONTEXT_LENGTH = 64_000
# Thin fallback defaults — only broad model family patterns.
# These fire only when provider is unknown AND models.dev/OpenRouter/Anthropic
# all miss. Replaced the previous 80+ entry dict.
@@ -96,34 +107,58 @@ DEFAULT_CONTEXT_LENGTHS = {
"claude-sonnet-4.6": 1000000,
# Catch-all for older Claude models (must sort after specific entries)
"claude": 200000,
# OpenAI
# OpenAI — GPT-5 family (most have 400k; specific overrides first)
# Source: https://developers.openai.com/api/docs/models
"gpt-5.4-nano": 400000, # 400k (not 1.05M like full 5.4)
"gpt-5.4-mini": 400000, # 400k (not 1.05M like full 5.4)
"gpt-5.4": 1050000, # GPT-5.4, GPT-5.4 Pro (1.05M context)
"gpt-5.3-codex-spark": 128000, # Spark variant has reduced 128k context
"gpt-5.1-chat": 128000, # Chat variant has 128k context
"gpt-5": 400000, # GPT-5.x base, mini, codex variants (400k)
"gpt-4.1": 1047576,
"gpt-5": 128000,
"gpt-4": 128000,
# Google
"gemini": 1048576,
# Gemma (open models — Ollama / AI Studio)
# Gemma (open models served via AI Studio)
"gemma-4-31b": 256000,
"gemma-4-26b": 256000,
"gemma-4-12b": 256000,
"gemma-4-4b": 256000,
"gemma-4-1b": 256000,
"gemma-3": 131072,
"gemma": 8192, # fallback for older gemma models
# DeepSeek
"deepseek": 128000,
# Meta
"llama": 131072,
# Qwen
# Qwen — specific model families before the catch-all.
# Official docs: https://help.aliyun.com/zh/model-studio/developer-reference/
"qwen3-coder-plus": 1000000, # 1M context
"qwen3-coder": 262144, # 256K context
"qwen": 131072,
# MiniMax
# MiniMax — official docs: 204,800 context for all models
# https://platform.minimax.io/docs/api-reference/text-anthropic-api
"minimax": 204800,
# GLM
"glm": 202752,
# xAI Grok — xAI /v1/models does not return context_length metadata,
# so these hardcoded fallbacks prevent Hermes from probing-down to
# the default 128k when the user points at https://api.x.ai/v1
# via a custom provider. Values sourced from models.dev (2026-04).
# Keys use substring matching (longest-first), so e.g. "grok-4.20"
# matches "grok-4.20-0309-reasoning" / "-non-reasoning" / "-multi-agent-0309".
"grok-code-fast": 256000, # grok-code-fast-1
"grok-4-1-fast": 2000000, # grok-4-1-fast-(non-)reasoning
"grok-2-vision": 8192, # grok-2-vision, -1212, -latest
"grok-4-fast": 2000000, # grok-4-fast-(non-)reasoning
"grok-4.20": 2000000, # grok-4.20-0309-(non-)reasoning, -multi-agent-0309
"grok-4": 256000, # grok-4, grok-4-0709
"grok-3": 131072, # grok-3, grok-3-mini, grok-3-fast, grok-3-mini-fast
"grok-2": 131072, # grok-2, grok-2-1212, grok-2-latest
"grok": 131072, # catch-all (grok-beta, unknown grok-*)
# Kimi
"kimi": 262144,
# Arcee
"trinity": 262144,
# OpenRouter
"elephant": 262144,
# Hugging Face Inference Providers — model IDs use org/name format
"Qwen/Qwen3.5-397B-A17B": 131072,
"Qwen/Qwen3.5-35B-A3B": 131072,
@@ -131,9 +166,10 @@ DEFAULT_CONTEXT_LENGTHS = {
"moonshotai/Kimi-K2.5": 262144,
"moonshotai/Kimi-K2-Thinking": 262144,
"MiniMaxAI/MiniMax-M2.5": 204800,
"XiaomiMiMo/MiMo-V2-Flash": 32768,
"mimo-v2-pro": 1048576,
"mimo-v2-omni": 1048576,
"XiaomiMiMo/MiMo-V2-Flash": 256000,
"mimo-v2-pro": 1000000,
"mimo-v2-omni": 256000,
"mimo-v2-flash": 256000,
"zai-org/GLM-5": 202752,
}
@@ -158,6 +194,12 @@ _MAX_COMPLETION_KEYS = (
# Local server hostnames / address patterns
_LOCAL_HOSTS = ("localhost", "127.0.0.1", "::1", "0.0.0.0")
# Docker / Podman / Lima DNS names that resolve to the host machine
_CONTAINER_LOCAL_SUFFIXES = (
".docker.internal",
".containers.internal",
".lima.internal",
)
def _normalize_base_url(base_url: str) -> str:
@@ -179,10 +221,13 @@ _URL_TO_PROVIDER: Dict[str, str] = {
"api.anthropic.com": "anthropic",
"api.z.ai": "zai",
"api.moonshot.ai": "kimi-coding",
"api.moonshot.cn": "kimi-coding-cn",
"api.kimi.com": "kimi-coding",
"api.arcee.ai": "arcee",
"api.minimax": "minimax",
"dashscope.aliyuncs.com": "alibaba",
"dashscope-intl.aliyuncs.com": "alibaba",
"portal.qwen.ai": "qwen-oauth",
"openrouter.ai": "openrouter",
"generativelanguage.googleapis.com": "gemini",
"inference-api.nousresearch.com": "nous",
@@ -190,8 +235,10 @@ _URL_TO_PROVIDER: Dict[str, str] = {
"api.githubcopilot.com": "copilot",
"models.github.ai": "copilot",
"api.fireworks.ai": "fireworks",
"localhost": "ollama",
"127.0.0.1": "ollama",
"opencode.ai": "opencode-go",
"api.x.ai": "xai",
"api.xiaomimimo.com": "xiaomi",
"xiaomimimo.com": "xiaomi",
}
@@ -230,6 +277,9 @@ def is_local_endpoint(base_url: str) -> bool:
return False
if host in _LOCAL_HOSTS:
return True
# Docker / Podman / Lima internal DNS names (e.g. host.docker.internal)
if any(host.endswith(suffix) for suffix in _CONTAINER_LOCAL_SUFFIXES):
return True
# RFC-1918 private ranges and link-local
import ipaddress
try:
@@ -515,8 +565,8 @@ def fetch_endpoint_model_metadata(
def _get_context_cache_path() -> Path:
"""Return path to the persistent context length cache file."""
hermes_home = Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes"))
return hermes_home / "context_length_cache.yaml"
from hermes_constants import get_hermes_home
return get_hermes_home() / "context_length_cache.yaml"
def _load_context_cache() -> Dict[str, int]:
@@ -597,6 +647,49 @@ def parse_context_limit_from_error(error_msg: str) -> Optional[int]:
return None
def parse_available_output_tokens_from_error(error_msg: str) -> Optional[int]:
"""Detect an "output cap too large" error and return how many output tokens are available.
Background — two distinct context errors exist:
1. "Prompt too long" — the INPUT itself exceeds the context window.
Fix: compress history and/or halve context_length.
2. "max_tokens too large" — input is fine, but input + requested_output > window.
Fix: reduce max_tokens (the output cap) for this call.
Do NOT touch context_length — the window hasn't shrunk.
Anthropic's API returns errors like:
"max_tokens: 32768 > context_window: 200000 - input_tokens: 190000 = available_tokens: 10000"
Returns the number of output tokens that would fit (e.g. 10000 above), or None if
the error does not look like a max_tokens-too-large error.
"""
error_lower = error_msg.lower()
# Must look like an output-cap error, not a prompt-length error.
is_output_cap_error = (
"max_tokens" in error_lower
and ("available_tokens" in error_lower or "available tokens" in error_lower)
)
if not is_output_cap_error:
return None
# Extract the available_tokens figure.
# Anthropic format: "… = available_tokens: 10000"
patterns = [
r'available_tokens[:\s]+(\d+)',
r'available\s+tokens[:\s]+(\d+)',
# fallback: last number after "=" in expressions like "200000 - 190000 = 10000"
r'=\s*(\d+)\s*$',
]
for pattern in patterns:
match = re.search(pattern, error_lower)
if match:
tokens = int(match.group(1))
if tokens >= 1:
return tokens
return None
def _model_id_matches(candidate_id: str, lookup_model: str) -> bool:
"""Return True if *candidate_id* (from server) matches *lookup_model* (configured).
@@ -616,6 +709,59 @@ def _model_id_matches(candidate_id: str, lookup_model: str) -> bool:
return False
def query_ollama_num_ctx(model: str, base_url: str) -> Optional[int]:
"""Query an Ollama server for the model's context length.
Returns the model's maximum context from GGUF metadata via ``/api/show``,
or the explicit ``num_ctx`` from the Modelfile if set. Returns None if
the server is unreachable or not Ollama.
This is the value that should be passed as ``num_ctx`` in Ollama chat
requests to override the default 2048.
"""
import httpx
bare_model = _strip_provider_prefix(model)
server_url = base_url.rstrip("/")
if server_url.endswith("/v1"):
server_url = server_url[:-3]
try:
server_type = detect_local_server_type(base_url)
except Exception:
return None
if server_type != "ollama":
return None
try:
with httpx.Client(timeout=3.0) as client:
resp = client.post(f"{server_url}/api/show", json={"name": bare_model})
if resp.status_code != 200:
return None
data = resp.json()
# Prefer explicit num_ctx from Modelfile parameters (user override)
params = data.get("parameters", "")
if "num_ctx" in params:
for line in params.split("\n"):
if "num_ctx" in line:
parts = line.strip().split()
if len(parts) >= 2:
try:
return int(parts[-1])
except ValueError:
pass
# Fall back to GGUF model_info context_length (training max)
model_info = data.get("model_info", {})
for key, value in model_info.items():
if "context_length" in key and isinstance(value, (int, float)):
return int(value)
except Exception:
pass
return None
def _query_local_context_length(model: str, base_url: str) -> Optional[int]:
"""Query a local server for the model's context length."""
import httpx
@@ -641,12 +787,12 @@ def _query_local_context_length(model: str, base_url: str) -> Optional[int]:
resp = client.post(f"{server_url}/api/show", json={"name": model})
if resp.status_code == 200:
data = resp.json()
# Check model_info for context length
model_info = data.get("model_info", {})
for key, value in model_info.items():
if "context_length" in key and isinstance(value, (int, float)):
return int(value)
# Check parameters string for num_ctx
# Prefer explicit num_ctx from Modelfile parameters: this is
# the *runtime* context Ollama will actually allocate KV cache
# for. The GGUF model_info.context_length is the training max,
# which can be larger than num_ctx — using it here would let
# Hermes grow conversations past the runtime limit and Ollama
# would silently truncate. Matches query_ollama_num_ctx().
params = data.get("parameters", "")
if "num_ctx" in params:
for line in params.split("\n"):
@@ -657,6 +803,11 @@ def _query_local_context_length(model: str, base_url: str) -> Optional[int]:
return int(parts[-1])
except ValueError:
pass
# Fall back to GGUF model_info context_length (training max)
model_info = data.get("model_info", {})
for key, value in model_info.items():
if "context_length" in key and isinstance(value, (int, float)):
return int(value)
# LM Studio native API: /api/v1/models returns max_context_length.
# This is more reliable than the OpenAI-compat /v1/models which
@@ -911,16 +1062,21 @@ def get_model_context_length(
def estimate_tokens_rough(text: str) -> int:
"""Rough token estimate (~4 chars/token) for pre-flight checks."""
"""Rough token estimate (~4 chars/token) for pre-flight checks.
Uses ceiling division so short texts (1-3 chars) never estimate as
0 tokens, which would cause the compressor and pre-flight checks to
systematically undercount when many short tool results are present.
"""
if not text:
return 0
return len(text) // 4
return (len(text) + 3) // 4
def estimate_messages_tokens_rough(messages: List[Dict[str, Any]]) -> int:
"""Rough token estimate for a message list (pre-flight only)."""
total_chars = sum(len(str(msg)) for msg in messages)
return total_chars // 4
return (total_chars + 3) // 4
def estimate_request_tokens_rough(
@@ -943,4 +1099,4 @@ def estimate_request_tokens_rough(
total_chars += sum(len(str(msg)) for msg in messages)
if tools:
total_chars += len(str(tools))
return total_chars // 4
return (total_chars + 3) // 4

View File

@@ -18,14 +18,12 @@ Other modules should import the dataclasses and query functions from here
rather than parsing the raw JSON themselves.
"""
import difflib
import json
import logging
import os
import time
from dataclasses import dataclass, field
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple, Union
from typing import Any, Dict, List, Optional, Tuple
from utils import atomic_json_write
@@ -135,9 +133,6 @@ class ProviderInfo:
doc: str = "" # documentation URL
model_count: int = 0
def has_api_url(self) -> bool:
return bool(self.api)
# ---------------------------------------------------------------------------
# Provider ID mapping: Hermes ↔ models.dev
@@ -147,12 +142,16 @@ class ProviderInfo:
PROVIDER_TO_MODELS_DEV: Dict[str, str] = {
"openrouter": "openrouter",
"anthropic": "anthropic",
"openai": "openai",
"openai-codex": "openai",
"zai": "zai",
"kimi-coding": "kimi-k2.5",
"kimi-coding": "kimi-for-coding",
"kimi-coding-cn": "kimi-for-coding",
"minimax": "minimax",
"minimax-cn": "minimax-cn",
"deepseek": "deepseek",
"alibaba": "alibaba",
"qwen-oauth": "alibaba",
"copilot": "github-copilot",
"ai-gateway": "vercel",
"opencode-zen": "opencode",
@@ -163,6 +162,7 @@ PROVIDER_TO_MODELS_DEV: Dict[str, str] = {
"gemini": "google",
"google": "google",
"xai": "xai",
"xiaomi": "xiaomi",
"nvidia": "nvidia",
"groq": "groq",
"mistral": "mistral",
@@ -175,19 +175,11 @@ PROVIDER_TO_MODELS_DEV: Dict[str, str] = {
_MODELS_DEV_TO_PROVIDER: Optional[Dict[str, str]] = None
def _get_reverse_mapping() -> Dict[str, str]:
"""Return models.dev ID → Hermes provider ID mapping."""
global _MODELS_DEV_TO_PROVIDER
if _MODELS_DEV_TO_PROVIDER is None:
_MODELS_DEV_TO_PROVIDER = {v: k for k, v in PROVIDER_TO_MODELS_DEV.items()}
return _MODELS_DEV_TO_PROVIDER
def _get_cache_path() -> Path:
"""Return path to disk cache file."""
env_val = os.environ.get("HERMES_HOME", "")
hermes_home = Path(env_val) if env_val else Path.home() / ".hermes"
return hermes_home / "models_dev_cache.json"
from hermes_constants import get_hermes_home
return get_hermes_home() / "models_dev_cache.json"
def _load_disk_cache() -> Dict[str, Any]:
@@ -231,7 +223,7 @@ def fetch_models_dev(force_refresh: bool = False) -> Dict[str, Any]:
response = requests.get(MODELS_DEV_URL, timeout=15)
response.raise_for_status()
data = response.json()
if isinstance(data, dict) and len(data) > 0:
if isinstance(data, dict) and data:
_models_dev_cache = data
_models_dev_cache_time = time.time()
_save_disk_cache(data)
@@ -386,7 +378,14 @@ def get_model_capabilities(provider: str, model: str) -> Optional[ModelCapabilit
# Extract capability flags (default to False if missing)
supports_tools = bool(entry.get("tool_call", False))
supports_vision = bool(entry.get("attachment", False))
# Vision: check both the `attachment` flag and `modalities.input` for "image".
# Some models (e.g. gemma-4) list image in input modalities but not attachment.
input_mods = entry.get("modalities", {})
if isinstance(input_mods, dict):
input_mods = input_mods.get("input", [])
else:
input_mods = []
supports_vision = bool(entry.get("attachment", False)) or "image" in input_mods
supports_reasoning = bool(entry.get("reasoning", False))
# Extract limits
@@ -456,93 +455,6 @@ def list_agentic_models(provider: str) -> List[str]:
return result
def search_models_dev(
query: str, provider: str = None, limit: int = 5
) -> List[Dict[str, Any]]:
"""Fuzzy search across models.dev catalog. Returns matching model entries.
Args:
query: Search string to match against model IDs.
provider: Optional Hermes provider ID to restrict search scope.
If None, searches across all providers in PROVIDER_TO_MODELS_DEV.
limit: Maximum number of results to return.
Returns:
List of dicts, each containing 'provider', 'model_id', and the full
model 'entry' from models.dev.
"""
data = fetch_models_dev()
if not data:
return []
# Build list of (provider_id, model_id, entry) candidates
candidates: List[tuple] = []
if provider is not None:
# Search only the specified provider
mdev_provider_id = PROVIDER_TO_MODELS_DEV.get(provider)
if not mdev_provider_id:
return []
provider_data = data.get(mdev_provider_id, {})
if isinstance(provider_data, dict):
models = provider_data.get("models", {})
if isinstance(models, dict):
for mid, mdata in models.items():
candidates.append((provider, mid, mdata))
else:
# Search across all mapped providers
for hermes_prov, mdev_prov in PROVIDER_TO_MODELS_DEV.items():
provider_data = data.get(mdev_prov, {})
if isinstance(provider_data, dict):
models = provider_data.get("models", {})
if isinstance(models, dict):
for mid, mdata in models.items():
candidates.append((hermes_prov, mid, mdata))
if not candidates:
return []
# Use difflib for fuzzy matching — case-insensitive comparison
model_ids_lower = [c[1].lower() for c in candidates]
query_lower = query.lower()
# First try exact substring matches (more intuitive than pure edit-distance)
substring_matches = []
for prov, mid, mdata in candidates:
if query_lower in mid.lower():
substring_matches.append({"provider": prov, "model_id": mid, "entry": mdata})
# Then add difflib fuzzy matches for any remaining slots
fuzzy_ids = difflib.get_close_matches(
query_lower, model_ids_lower, n=limit * 2, cutoff=0.4
)
seen_ids: set = set()
results: List[Dict[str, Any]] = []
# Prioritize substring matches
for match in substring_matches:
key = (match["provider"], match["model_id"])
if key not in seen_ids:
seen_ids.add(key)
results.append(match)
if len(results) >= limit:
return results
# Add fuzzy matches
for fid in fuzzy_ids:
# Find original-case candidates matching this lowered ID
for prov, mid, mdata in candidates:
if mid.lower() == fid:
key = (prov, mid)
if key not in seen_ids:
seen_ids.add(key)
results.append({"provider": prov, "model_id": mid, "entry": mdata})
if len(results) >= limit:
return results
return results
# ---------------------------------------------------------------------------
# Rich dataclass constructors — parse raw models.dev JSON into dataclasses
@@ -634,43 +546,6 @@ def get_provider_info(provider_id: str) -> Optional[ProviderInfo]:
return _parse_provider_info(mdev_id, raw)
def list_all_providers() -> Dict[str, ProviderInfo]:
"""Return all providers from models.dev as {provider_id: ProviderInfo}.
Returns the full catalog — 109+ providers. For providers that have
a Hermes alias, both the models.dev ID and the Hermes ID are included.
"""
data = fetch_models_dev()
result: Dict[str, ProviderInfo] = {}
for pid, pdata in data.items():
if isinstance(pdata, dict):
info = _parse_provider_info(pid, pdata)
result[pid] = info
return result
def get_providers_for_env_var(env_var: str) -> List[str]:
"""Reverse lookup: find all providers that use a given env var.
Useful for auto-detection: "user has ANTHROPIC_API_KEY set, which
providers does that enable?"
Returns list of models.dev provider IDs.
"""
data = fetch_models_dev()
matches: List[str] = []
for pid, pdata in data.items():
if isinstance(pdata, dict):
env = pdata.get("env", [])
if isinstance(env, list) and env_var in env:
matches.append(pid)
return matches
# ---------------------------------------------------------------------------
# Model-level queries (rich ModelInfo)
# ---------------------------------------------------------------------------
@@ -708,74 +583,3 @@ def get_model_info(
return None
def get_model_info_any_provider(model_id: str) -> Optional[ModelInfo]:
"""Search all providers for a model by ID.
Useful when you have a full slug like "anthropic/claude-sonnet-4.6" or
a bare name and want to find it anywhere. Checks Hermes-mapped providers
first, then falls back to all models.dev providers.
"""
data = fetch_models_dev()
# Try Hermes-mapped providers first (more likely what the user wants)
for hermes_id, mdev_id in PROVIDER_TO_MODELS_DEV.items():
pdata = data.get(mdev_id)
if not isinstance(pdata, dict):
continue
models = pdata.get("models", {})
if not isinstance(models, dict):
continue
raw = models.get(model_id)
if isinstance(raw, dict):
return _parse_model_info(model_id, raw, mdev_id)
# Case-insensitive
model_lower = model_id.lower()
for mid, mdata in models.items():
if mid.lower() == model_lower and isinstance(mdata, dict):
return _parse_model_info(mid, mdata, mdev_id)
# Fall back to ALL providers
for pid, pdata in data.items():
if pid in _get_reverse_mapping():
continue # already checked
if not isinstance(pdata, dict):
continue
models = pdata.get("models", {})
if not isinstance(models, dict):
continue
raw = models.get(model_id)
if isinstance(raw, dict):
return _parse_model_info(model_id, raw, pid)
return None
def list_provider_model_infos(provider_id: str) -> List[ModelInfo]:
"""Return all models for a provider as ModelInfo objects.
Filters out deprecated models by default.
"""
mdev_id = PROVIDER_TO_MODELS_DEV.get(provider_id, provider_id)
data = fetch_models_dev()
pdata = data.get(mdev_id)
if not isinstance(pdata, dict):
return []
models = pdata.get("models", {})
if not isinstance(models, dict):
return []
result: List[ModelInfo] = []
for mid, mdata in models.items():
if not isinstance(mdata, dict):
continue
status = mdata.get("status", "")
if status == "deprecated":
continue
result.append(_parse_model_info(mid, mdata, mdev_id))
return result

184
agent/mtls.py Normal file
View File

@@ -0,0 +1,184 @@
"""
agent/mtls.py — Mutual TLS support for Hermes A2A communication.
Provides:
- build_server_ssl_context() — SSL context for uvicorn that requires client certs
- build_client_ssl_context() — SSL context for httpx/aiohttp A2A clients
- MTLSMiddleware — FastAPI middleware that enforces client cert on A2A routes
- is_mtls_configured() — Check if env vars are set
Configuration (environment variables):
HERMES_MTLS_CERT Path to this agent's TLS certificate (PEM)
HERMES_MTLS_KEY Path to this agent's TLS private key (PEM)
HERMES_MTLS_CA Path to the Fleet CA certificate (PEM) — used to verify peers
All three must be set to enable mTLS. If any is missing, mTLS is disabled and
the server falls back to plain HTTP (or regular TLS without client auth).
"""
import logging
import os
import ssl
from pathlib import Path
from typing import Optional
logger = logging.getLogger(__name__)
# A2A routes that require a valid client certificate when mTLS is enabled.
_A2A_PATH_PREFIXES = (
"/.well-known/agent-card",
"/agent-card",
"/api/agent-card",
"/a2a/",
)
def _get_env(key: str) -> Optional[str]:
val = os.environ.get(key, "").strip()
return val or None
def is_mtls_configured() -> bool:
"""Return True if all three mTLS env vars are set and the files exist."""
cert = _get_env("HERMES_MTLS_CERT")
key = _get_env("HERMES_MTLS_KEY")
ca = _get_env("HERMES_MTLS_CA")
if not (cert and key and ca):
return False
for label, path in (("HERMES_MTLS_CERT", cert), ("HERMES_MTLS_KEY", key), ("HERMES_MTLS_CA", ca)):
if not Path(path).is_file():
logger.warning("mTLS disabled: %s file not found: %s", label, path)
return False
return True
def build_server_ssl_context() -> ssl.SSLContext:
"""
Build an SSL context for the A2A server that:
- presents its own certificate
- requires and verifies the client's certificate against the Fleet CA
Raises:
RuntimeError: if mTLS env vars are not set or files are missing
ssl.SSLError: if cert/key/CA files are invalid
"""
cert = _get_env("HERMES_MTLS_CERT")
key = _get_env("HERMES_MTLS_KEY")
ca = _get_env("HERMES_MTLS_CA")
if not (cert and key and ca):
raise RuntimeError(
"mTLS not configured. Set HERMES_MTLS_CERT, HERMES_MTLS_KEY, and HERMES_MTLS_CA."
)
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
ctx.load_cert_chain(certfile=cert, keyfile=key)
ctx.load_verify_locations(cafile=ca)
# CERT_REQUIRED: reject connections without a valid client cert
ctx.verify_mode = ssl.CERT_REQUIRED
logger.info("mTLS server context built (cert=%s, CA=%s)", cert, ca)
return ctx
def build_client_ssl_context() -> ssl.SSLContext:
"""
Build an SSL context for outbound A2A connections that:
- presents this agent's certificate as a client cert
- verifies the remote server against the Fleet CA
Raises:
RuntimeError: if mTLS env vars are not set or files are missing
ssl.SSLError: if cert/key/CA files are invalid
"""
cert = _get_env("HERMES_MTLS_CERT")
key = _get_env("HERMES_MTLS_KEY")
ca = _get_env("HERMES_MTLS_CA")
if not (cert and key and ca):
raise RuntimeError(
"mTLS not configured. Set HERMES_MTLS_CERT, HERMES_MTLS_KEY, and HERMES_MTLS_CA."
)
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
ctx.load_cert_chain(certfile=cert, keyfile=key)
ctx.load_verify_locations(cafile=ca)
ctx.verify_mode = ssl.CERT_REQUIRED
ctx.check_hostname = True
logger.info("mTLS client context built (cert=%s, CA=%s)", cert, ca)
return ctx
def get_peer_cn(ssl_object) -> Optional[str]:
"""Extract the CN from the peer certificate's subject, or None."""
try:
peer_cert = ssl_object.getpeercert()
if not peer_cert:
return None
for rdn in peer_cert.get("subject", ()):
for attr, value in rdn:
if attr == "commonName":
return value
except Exception:
pass
return None
class MTLSMiddleware:
"""
ASGI middleware that enforces client certificate verification on A2A routes.
When mTLS is NOT configured (no env vars) or the route is not an A2A route,
the request passes through unchanged.
When mTLS IS configured and the route matches an A2A prefix, the middleware
checks that the request arrived over a TLS connection with a verified client
certificate. If not, it returns HTTP 403.
Note: This middleware only provides defence-in-depth at the app layer.
The primary enforcement is at the SSL context level (CERT_REQUIRED on the
server context). This middleware is useful when the server runs behind a
TLS-terminating proxy that forwards cert info via headers (not yet
implemented) or for test-time injection.
"""
def __init__(self, app):
self.app = app
self._enabled = is_mtls_configured()
if self._enabled:
logger.info("MTLSMiddleware enabled — A2A routes require client cert")
def _is_a2a_route(self, path: str) -> bool:
return any(path.startswith(prefix) for prefix in _A2A_PATH_PREFIXES)
async def __call__(self, scope, receive, send):
if scope["type"] == "http" and self._enabled and self._is_a2a_route(scope.get("path", "")):
# Check for client cert in the SSL connection
transport = scope.get("extensions", {}).get("tls", {})
peer_cert = transport.get("peer_cert")
if peer_cert is None:
# No client cert — reject
response = _forbidden_response("Client certificate required for A2A endpoints")
await response(scope, receive, send)
return
await self.app(scope, receive, send)
def _forbidden_response(message: str):
"""Return a minimal ASGI 403 response."""
body = message.encode()
async def respond(scope, receive, send):
await send({
"type": "http.response.start",
"status": 403,
"headers": [
(b"content-type", b"text/plain"),
(b"content-length", str(len(body)).encode()),
],
})
await send({"type": "http.response.body", "body": body})
return respond

View File

@@ -1,813 +0,0 @@
#!/usr/bin/env python3
"""
Nexus Architect AI Agent
Autonomous Three.js world generation system for Timmy's Nexus.
Generates valid Three.js scene code from natural language descriptions
and mental state integration.
This module provides:
- LLM-driven immersive environment generation
- Mental state integration for aesthetic tuning
- Three.js code generation with validation
- Scene composition from mood descriptions
"""
import json
import logging
import re
from typing import Dict, Any, List, Optional, Union
from dataclasses import dataclass, field
from enum import Enum
import os
import sys
# Add parent directory to path for imports
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
logger = logging.getLogger(__name__)
# =============================================================================
# Aesthetic Constants (from SOUL.md values)
# =============================================================================
class NexusColors:
"""Nexus color palette based on SOUL.md values."""
TIMMY_GOLD = "#D4AF37" # Warm gold
ALLEGRO_BLUE = "#4A90E2" # Motion blue
SOVEREIGNTY_CRYSTAL = "#E0F7FA" # Crystalline structures
SERVICE_WARMTH = "#FFE4B5" # Welcoming warmth
DEFAULT_AMBIENT = "#1A1A2E" # Contemplative dark
HOPE_ACCENT = "#64B5F6" # Hopeful blue
class MoodPresets:
"""Mood-based aesthetic presets."""
CONTEMPLATIVE = {
"lighting": "soft_diffuse",
"colors": ["#1A1A2E", "#16213E", "#0F3460"],
"geometry": "minimalist",
"atmosphere": "calm",
"description": "A serene space for deep reflection and clarity"
}
ENERGETIC = {
"lighting": "dynamic_vivid",
"colors": ["#D4AF37", "#FF6B6B", "#4ECDC4"],
"geometry": "angular_dynamic",
"atmosphere": "lively",
"description": "An invigorating space full of motion and possibility"
}
MYSTERIOUS = {
"lighting": "dramatic_shadows",
"colors": ["#2C003E", "#512B58", "#8B4F80"],
"geometry": "organic_flowing",
"atmosphere": "enigmatic",
"description": "A mysterious realm of discovery and wonder"
}
WELCOMING = {
"lighting": "warm_inviting",
"colors": ["#FFE4B5", "#FFA07A", "#98D8C8"],
"geometry": "rounded_soft",
"atmosphere": "friendly",
"description": "An open, welcoming space that embraces visitors"
}
SOVEREIGN = {
"lighting": "crystalline_clear",
"colors": ["#E0F7FA", "#B2EBF2", "#4DD0E1"],
"geometry": "crystalline_structures",
"atmosphere": "noble",
"description": "A space of crystalline clarity and sovereign purpose"
}
# =============================================================================
# Data Models
# =============================================================================
@dataclass
class MentalState:
"""Timmy's mental state for aesthetic tuning."""
mood: str = "contemplative" # contemplative, energetic, mysterious, welcoming, sovereign
energy_level: float = 0.5 # 0.0 to 1.0
clarity: float = 0.7 # 0.0 to 1.0
focus_area: str = "general" # general, creative, analytical, social
timestamp: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
return {
"mood": self.mood,
"energy_level": self.energy_level,
"clarity": self.clarity,
"focus_area": self.focus_area,
"timestamp": self.timestamp,
}
@dataclass
class RoomDesign:
"""Complete room design specification."""
name: str
description: str
style: str
dimensions: Dict[str, float] = field(default_factory=lambda: {"width": 20, "height": 10, "depth": 20})
mood_preset: str = "contemplative"
color_palette: List[str] = field(default_factory=list)
lighting_scheme: str = "soft_diffuse"
features: List[str] = field(default_factory=list)
generated_code: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
return {
"name": self.name,
"description": self.description,
"style": self.style,
"dimensions": self.dimensions,
"mood_preset": self.mood_preset,
"color_palette": self.color_palette,
"lighting_scheme": self.lighting_scheme,
"features": self.features,
"has_code": self.generated_code is not None,
}
@dataclass
class PortalDesign:
"""Portal connection design."""
name: str
from_room: str
to_room: str
style: str
position: Dict[str, float] = field(default_factory=lambda: {"x": 0, "y": 0, "z": 0})
visual_effect: str = "energy_swirl"
transition_duration: float = 1.5
generated_code: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
return {
"name": self.name,
"from_room": self.from_room,
"to_room": self.to_room,
"style": self.style,
"position": self.position,
"visual_effect": self.visual_effect,
"transition_duration": self.transition_duration,
"has_code": self.generated_code is not None,
}
# =============================================================================
# Prompt Engineering
# =============================================================================
class PromptEngineer:
"""Engineers prompts for Three.js code generation."""
THREE_JS_BASE_TEMPLATE = """// Nexus Room Module: {room_name}
// Style: {style}
// Mood: {mood}
// Generated for Three.js r128+
(function() {{
'use strict';
// Room Configuration
const config = {{
name: "{room_name}",
dimensions: {dimensions_json},
colors: {colors_json},
mood: "{mood}"
}};
// Create Room Function
function create{room_name_camel}() {{
const roomGroup = new THREE.Group();
roomGroup.name = config.name;
{room_content}
return roomGroup;
}}
// Export for Nexus
if (typeof module !== 'undefined' && module.exports) {{
module.exports = {{ create{room_name_camel} }};
}} else if (typeof window !== 'undefined') {{
window.NexusRooms = window.NexusRooms || {{}};
window.NexusRooms.{room_name} = create{room_name_camel};
}}
return {{ create{room_name_camel} }};
}})();"""
@staticmethod
def engineer_room_prompt(
name: str,
description: str,
style: str,
mental_state: Optional[MentalState] = None,
dimensions: Optional[Dict[str, float]] = None
) -> str:
"""
Engineer an LLM prompt for room generation.
Args:
name: Room identifier
description: Natural language room description
style: Visual style
mental_state: Timmy's current mental state
dimensions: Room dimensions
"""
# Determine mood from mental state or description
mood = PromptEngineer._infer_mood(description, mental_state)
mood_preset = getattr(MoodPresets, mood.upper(), MoodPresets.CONTEMPLATIVE)
# Build color palette
color_palette = mood_preset["colors"]
if mental_state:
# Add Timmy's gold for high clarity states
if mental_state.clarity > 0.7:
color_palette = [NexusColors.TIMMY_GOLD] + color_palette[:2]
# Add Allegro blue for creative focus
if mental_state.focus_area == "creative":
color_palette = [NexusColors.ALLEGRO_BLUE] + color_palette[:2]
# Create the engineering prompt
prompt = f"""You are the Nexus Architect, an expert Three.js developer creating immersive 3D environments for Timmy.
DESIGN BRIEF:
- Room Name: {name}
- Description: {description}
- Style: {style}
- Mood: {mood}
- Atmosphere: {mood_preset['atmosphere']}
AESTHETIC GUIDELINES:
- Primary Colors: {', '.join(color_palette[:3])}
- Lighting: {mood_preset['lighting']}
- Geometry: {mood_preset['geometry']}
- Theme: {mood_preset['description']}
TIMMY'S CONTEXT:
- Timmy's Signature Color: Warm Gold ({NexusColors.TIMMY_GOLD})
- Allegro's Color: Motion Blue ({NexusColors.ALLEGRO_BLUE})
- Sovereignty Theme: Crystalline structures, clean lines
- Service Theme: Open spaces, welcoming lighting
THREE.JS REQUIREMENTS:
1. Use Three.js r128+ compatible syntax
2. Create a self-contained module with a `create{name.title().replace('_', '')}()` function
3. Return a THREE.Group containing all room elements
4. Include proper memory management (dispose methods)
5. Use MeshStandardMaterial for PBR lighting
6. Include ambient light (intensity 0.3-0.5) + accent lights
7. Add subtle animations for living feel
8. Keep polygon count under 10,000 triangles
SAFETY RULES:
- NO eval(), Function(), or dynamic code execution
- NO network requests (fetch, XMLHttpRequest, WebSocket)
- NO storage access (localStorage, sessionStorage, cookies)
- NO navigation (window.location, window.open)
- Only use allowed Three.js APIs
OUTPUT FORMAT:
Return ONLY the JavaScript code wrapped in a markdown code block:
```javascript
// Your Three.js room module here
```
Generate the complete Three.js code for this room now."""
return prompt
@staticmethod
def engineer_portal_prompt(
name: str,
from_room: str,
to_room: str,
style: str,
mental_state: Optional[MentalState] = None
) -> str:
"""Engineer a prompt for portal generation."""
mood = PromptEngineer._infer_mood(f"portal from {from_room} to {to_room}", mental_state)
prompt = f"""You are creating a portal connection in the Nexus 3D environment.
PORTAL SPECIFICATIONS:
- Name: {name}
- Connection: {from_room}{to_room}
- Style: {style}
- Context Mood: {mood}
VISUAL REQUIREMENTS:
1. Create an animated portal effect (shader or texture-based)
2. Include particle system for energy flow
3. Add trigger zone for teleportation detection
4. Use signature colors: {NexusColors.TIMMY_GOLD} (Timmy) and {NexusColors.ALLEGRO_BLUE} (Allegro)
5. Match the {mood} atmosphere
TECHNICAL REQUIREMENTS:
- Three.js r128+ compatible
- Export a `createPortal()` function returning THREE.Group
- Include animation loop hook
- Add collision detection placeholder
SAFETY: No eval, no network requests, no external dependencies.
Return ONLY JavaScript code in a markdown code block."""
return prompt
@staticmethod
def engineer_mood_scene_prompt(mood_description: str) -> str:
"""Engineer a prompt based on mood description."""
# Analyze mood description
mood_keywords = {
"contemplative": ["thinking", "reflective", "calm", "peaceful", "quiet", "serene"],
"energetic": ["excited", "dynamic", "lively", "active", "energetic", "vibrant"],
"mysterious": ["mysterious", "dark", "unknown", "secret", "enigmatic"],
"welcoming": ["friendly", "open", "warm", "welcoming", "inviting", "comfortable"],
"sovereign": ["powerful", "clear", "crystalline", "noble", "dignified"],
}
detected_mood = "contemplative"
desc_lower = mood_description.lower()
for mood, keywords in mood_keywords.items():
if any(kw in desc_lower for kw in keywords):
detected_mood = mood
break
preset = getattr(MoodPresets, detected_mood.upper(), MoodPresets.CONTEMPLATIVE)
prompt = f"""Generate a Three.js room based on this mood description:
"{mood_description}"
INFERRED MOOD: {detected_mood}
AESTHETIC: {preset['description']}
Create a complete room with:
- Style: {preset['geometry']}
- Lighting: {preset['lighting']}
- Color Palette: {', '.join(preset['colors'][:3])}
- Atmosphere: {preset['atmosphere']}
Return Three.js r128+ code as a module with `createMoodRoom()` function."""
return prompt
@staticmethod
def _infer_mood(description: str, mental_state: Optional[MentalState] = None) -> str:
"""Infer mood from description and mental state."""
if mental_state and mental_state.mood:
return mental_state.mood
desc_lower = description.lower()
mood_map = {
"contemplative": ["serene", "calm", "peaceful", "quiet", "meditation", "zen", "tranquil"],
"energetic": ["dynamic", "active", "vibrant", "lively", "energetic", "motion"],
"mysterious": ["mysterious", "shadow", "dark", "unknown", "secret", "ethereal"],
"welcoming": ["warm", "welcoming", "friendly", "open", "inviting", "comfort"],
"sovereign": ["crystal", "clear", "noble", "dignified", "powerful", "authoritative"],
}
for mood, keywords in mood_map.items():
if any(kw in desc_lower for kw in keywords):
return mood
return "contemplative"
# =============================================================================
# Nexus Architect AI
# =============================================================================
class NexusArchitectAI:
"""
AI-powered Nexus Architect for autonomous Three.js world generation.
This class provides high-level interfaces for:
- Designing rooms from natural language
- Creating mood-based scenes
- Managing mental state integration
- Validating generated code
"""
def __init__(self):
self.mental_state: Optional[MentalState] = None
self.room_designs: Dict[str, RoomDesign] = {}
self.portal_designs: Dict[str, PortalDesign] = {}
self.prompt_engineer = PromptEngineer()
def set_mental_state(self, state: MentalState) -> None:
"""Set Timmy's current mental state for aesthetic tuning."""
self.mental_state = state
logger.info(f"Mental state updated: {state.mood} (energy: {state.energy_level})")
def design_room(
self,
name: str,
description: str,
style: str,
dimensions: Optional[Dict[str, float]] = None
) -> Dict[str, Any]:
"""
Design a room from natural language description.
Args:
name: Room identifier (e.g., "contemplation_chamber")
description: Natural language description of the room
style: Visual style (e.g., "minimalist_ethereal", "crystalline_modern")
dimensions: Optional room dimensions
Returns:
Dict containing design specification and LLM prompt
"""
# Infer mood and select preset
mood = self.prompt_engineer._infer_mood(description, self.mental_state)
mood_preset = getattr(MoodPresets, mood.upper(), MoodPresets.CONTEMPLATIVE)
# Build color palette with mental state influence
colors = mood_preset["colors"].copy()
if self.mental_state:
if self.mental_state.clarity > 0.7:
colors.insert(0, NexusColors.TIMMY_GOLD)
if self.mental_state.focus_area == "creative":
colors.insert(0, NexusColors.ALLEGRO_BLUE)
# Create room design
design = RoomDesign(
name=name,
description=description,
style=style,
dimensions=dimensions or {"width": 20, "height": 10, "depth": 20},
mood_preset=mood,
color_palette=colors[:4],
lighting_scheme=mood_preset["lighting"],
features=self._extract_features(description),
)
# Generate LLM prompt
prompt = self.prompt_engineer.engineer_room_prompt(
name=name,
description=description,
style=style,
mental_state=self.mental_state,
dimensions=design.dimensions,
)
# Store design
self.room_designs[name] = design
return {
"success": True,
"room_name": name,
"design": design.to_dict(),
"llm_prompt": prompt,
"message": f"Room '{name}' designed. Use the LLM prompt to generate Three.js code.",
}
def create_portal(
self,
name: str,
from_room: str,
to_room: str,
style: str = "energy_vortex"
) -> Dict[str, Any]:
"""
Design a portal connection between rooms.
Args:
name: Portal identifier
from_room: Source room name
to_room: Target room name
style: Portal visual style
Returns:
Dict containing portal design and LLM prompt
"""
if from_room not in self.room_designs:
return {"success": False, "error": f"Source room '{from_room}' not found"}
if to_room not in self.room_designs:
return {"success": False, "error": f"Target room '{to_room}' not found"}
design = PortalDesign(
name=name,
from_room=from_room,
to_room=to_room,
style=style,
)
prompt = self.prompt_engineer.engineer_portal_prompt(
name=name,
from_room=from_room,
to_room=to_room,
style=style,
mental_state=self.mental_state,
)
self.portal_designs[name] = design
return {
"success": True,
"portal_name": name,
"design": design.to_dict(),
"llm_prompt": prompt,
"message": f"Portal '{name}' designed connecting {from_room} to {to_room}",
}
def generate_scene_from_mood(self, mood_description: str) -> Dict[str, Any]:
"""
Generate a complete scene based on mood description.
Args:
mood_description: Description of desired mood/atmosphere
Returns:
Dict containing scene design and LLM prompt
"""
# Infer mood
mood = self.prompt_engineer._infer_mood(mood_description, self.mental_state)
preset = getattr(MoodPresets, mood.upper(), MoodPresets.CONTEMPLATIVE)
# Create room name from mood
room_name = f"{mood}_realm"
# Generate prompt
prompt = self.prompt_engineer.engineer_mood_scene_prompt(mood_description)
return {
"success": True,
"room_name": room_name,
"inferred_mood": mood,
"aesthetic": preset,
"llm_prompt": prompt,
"message": f"Generated {mood} scene from mood description",
}
def _extract_features(self, description: str) -> List[str]:
"""Extract room features from description."""
features = []
feature_keywords = {
"floating": ["floating", "levitating", "hovering"],
"water": ["water", "fountain", "pool", "stream", "lake"],
"vegetation": ["tree", "plant", "garden", "forest", "nature"],
"crystals": ["crystal", "gem", "prism", "diamond"],
"geometry": ["geometric", "shape", "sphere", "cube", "abstract"],
"particles": ["particle", "dust", "sparkle", "glow", "mist"],
}
desc_lower = description.lower()
for feature, keywords in feature_keywords.items():
if any(kw in desc_lower for kw in keywords):
features.append(feature)
return features
def get_design_summary(self) -> Dict[str, Any]:
"""Get summary of all designs."""
return {
"mental_state": self.mental_state.to_dict() if self.mental_state else None,
"rooms": {name: design.to_dict() for name, design in self.room_designs.items()},
"portals": {name: portal.to_dict() for name, portal in self.portal_designs.items()},
"total_rooms": len(self.room_designs),
"total_portals": len(self.portal_designs),
}
# =============================================================================
# Module-level functions for easy import
# =============================================================================
_architect_instance: Optional[NexusArchitectAI] = None
def get_architect() -> NexusArchitectAI:
"""Get or create the NexusArchitectAI singleton."""
global _architect_instance
if _architect_instance is None:
_architect_instance = NexusArchitectAI()
return _architect_instance
def create_room(
name: str,
description: str,
style: str,
dimensions: Optional[Dict[str, float]] = None
) -> Dict[str, Any]:
"""
Create a room design from description.
Args:
name: Room identifier
description: Natural language room description
style: Visual style (e.g., "minimalist_ethereal")
dimensions: Optional dimensions dict with width, height, depth
Returns:
Dict with design specification and LLM prompt for code generation
"""
architect = get_architect()
return architect.design_room(name, description, style, dimensions)
def create_portal(
name: str,
from_room: str,
to_room: str,
style: str = "energy_vortex"
) -> Dict[str, Any]:
"""
Create a portal between rooms.
Args:
name: Portal identifier
from_room: Source room name
to_room: Target room name
style: Visual style
Returns:
Dict with portal design and LLM prompt
"""
architect = get_architect()
return architect.create_portal(name, from_room, to_room, style)
def generate_scene_from_mood(mood_description: str) -> Dict[str, Any]:
"""
Generate a scene based on mood description.
Args:
mood_description: Description of desired mood
Example:
"Timmy is feeling introspective and seeking clarity"
→ Generates calm, minimalist space with clear sightlines
Returns:
Dict with scene design and LLM prompt
"""
architect = get_architect()
return architect.generate_scene_from_mood(mood_description)
def set_mental_state(
mood: str,
energy_level: float = 0.5,
clarity: float = 0.7,
focus_area: str = "general"
) -> Dict[str, Any]:
"""
Set Timmy's mental state for aesthetic tuning.
Args:
mood: Current mood (contemplative, energetic, mysterious, welcoming, sovereign)
energy_level: 0.0 to 1.0
clarity: 0.0 to 1.0
focus_area: general, creative, analytical, social
Returns:
Confirmation dict
"""
architect = get_architect()
state = MentalState(
mood=mood,
energy_level=energy_level,
clarity=clarity,
focus_area=focus_area,
)
architect.set_mental_state(state)
return {
"success": True,
"mental_state": state.to_dict(),
"message": f"Mental state set to {mood}",
}
def get_nexus_summary() -> Dict[str, Any]:
"""Get summary of all Nexus designs."""
architect = get_architect()
return architect.get_design_summary()
# =============================================================================
# Tool Schemas for integration
# =============================================================================
NEXUS_ARCHITECT_AI_SCHEMAS = {
"create_room": {
"name": "create_room",
"description": (
"Design a new 3D room in the Nexus from a natural language description. "
"Returns a design specification and LLM prompt for Three.js code generation. "
"The room will be styled according to Timmy's current mental state."
),
"parameters": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Unique room identifier (e.g., 'contemplation_chamber')"
},
"description": {
"type": "string",
"description": "Natural language description of the room"
},
"style": {
"type": "string",
"description": "Visual style (minimalist_ethereal, crystalline_modern, organic_natural, etc.)"
},
"dimensions": {
"type": "object",
"description": "Optional room dimensions",
"properties": {
"width": {"type": "number"},
"height": {"type": "number"},
"depth": {"type": "number"},
}
}
},
"required": ["name", "description", "style"]
}
},
"create_portal": {
"name": "create_portal",
"description": "Create a portal connection between two rooms",
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string"},
"from_room": {"type": "string"},
"to_room": {"type": "string"},
"style": {"type": "string", "default": "energy_vortex"},
},
"required": ["name", "from_room", "to_room"]
}
},
"generate_scene_from_mood": {
"name": "generate_scene_from_mood",
"description": (
"Generate a complete 3D scene based on a mood description. "
"Example: 'Timmy is feeling introspective' creates a calm, minimalist space."
),
"parameters": {
"type": "object",
"properties": {
"mood_description": {
"type": "string",
"description": "Description of desired mood or mental state"
}
},
"required": ["mood_description"]
}
},
"set_mental_state": {
"name": "set_mental_state",
"description": "Set Timmy's mental state to influence aesthetic generation",
"parameters": {
"type": "object",
"properties": {
"mood": {"type": "string"},
"energy_level": {"type": "number"},
"clarity": {"type": "number"},
"focus_area": {"type": "string"},
},
"required": ["mood"]
}
},
"get_nexus_summary": {
"name": "get_nexus_summary",
"description": "Get summary of all Nexus room and portal designs",
"parameters": {"type": "object", "properties": {}}
},
}
if __name__ == "__main__":
# Demo usage
print("Nexus Architect AI - Demo")
print("=" * 50)
# Set mental state
result = set_mental_state("contemplative", energy_level=0.3, clarity=0.8)
print(f"\nMental State: {result['mental_state']}")
# Create a room
result = create_room(
name="contemplation_chamber",
description="A serene circular room with floating geometric shapes and soft blue light",
style="minimalist_ethereal",
)
print(f"\nRoom Design: {json.dumps(result['design'], indent=2)}")
# Generate from mood
result = generate_scene_from_mood("Timmy is feeling introspective and seeking clarity")
print(f"\nMood Scene: {result['inferred_mood']} - {result['aesthetic']['description']}")

View File

@@ -1,752 +0,0 @@
#!/usr/bin/env python3
"""
Nexus Deployment System
Real-time deployment system for Nexus Three.js modules.
Provides hot-reload, validation, rollback, and versioning capabilities.
Features:
- Hot-reload Three.js modules without page refresh
- Syntax validation and Three.js API compliance checking
- Rollback on error
- Versioning for nexus modules
- Module registry and dependency tracking
Usage:
from agent.nexus_deployment import NexusDeployer
deployer = NexusDeployer()
# Deploy with hot-reload
result = deployer.deploy_module(room_code, module_name="zen_garden")
# Rollback if needed
deployer.rollback_module("zen_garden")
# Get module status
status = deployer.get_module_status("zen_garden")
"""
import json
import logging
import re
import os
import hashlib
from typing import Dict, Any, List, Optional, Set
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
# Import validation from existing nexus_architect (avoid circular imports)
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
def _import_validation():
"""Lazy import to avoid circular dependencies."""
try:
from tools.nexus_architect import validate_three_js_code, sanitize_three_js_code
return validate_three_js_code, sanitize_three_js_code
except ImportError:
# Fallback: define local validation functions
def validate_three_js_code(code, strict_mode=False):
"""Fallback validation."""
errors = []
if "eval(" in code:
errors.append("Security violation: eval detected")
if "Function(" in code:
errors.append("Security violation: Function constructor detected")
return type('ValidationResult', (), {
'is_valid': len(errors) == 0,
'errors': errors,
'warnings': []
})()
def sanitize_three_js_code(code):
"""Fallback sanitization."""
return code
return validate_three_js_code, sanitize_three_js_code
logger = logging.getLogger(__name__)
# =============================================================================
# Deployment States
# =============================================================================
class DeploymentStatus(Enum):
"""Status of a module deployment."""
PENDING = "pending"
VALIDATING = "validating"
DEPLOYING = "deploying"
ACTIVE = "active"
FAILED = "failed"
ROLLING_BACK = "rolling_back"
ROLLED_BACK = "rolled_back"
# =============================================================================
# Data Models
# =============================================================================
@dataclass
class ModuleVersion:
"""Version information for a Nexus module."""
version_id: str
module_name: str
code_hash: str
timestamp: str
changes: str = ""
author: str = "nexus_architect"
def to_dict(self) -> Dict[str, Any]:
return {
"version_id": self.version_id,
"module_name": self.module_name,
"code_hash": self.code_hash,
"timestamp": self.timestamp,
"changes": self.changes,
"author": self.author,
}
@dataclass
class DeployedModule:
"""A deployed Nexus module."""
name: str
code: str
status: DeploymentStatus
version: str
deployed_at: str
last_updated: str
validation_result: Dict[str, Any] = field(default_factory=dict)
error_log: List[str] = field(default_factory=list)
dependencies: Set[str] = field(default_factory=set)
hot_reload_supported: bool = True
def to_dict(self) -> Dict[str, Any]:
return {
"name": self.name,
"status": self.status.value,
"version": self.version,
"deployed_at": self.deployed_at,
"last_updated": self.last_updated,
"validation": self.validation_result,
"dependencies": list(self.dependencies),
"hot_reload_supported": self.hot_reload_supported,
"code_preview": self.code[:200] + "..." if len(self.code) > 200 else self.code,
}
# =============================================================================
# Nexus Deployer
# =============================================================================
class NexusDeployer:
"""
Deployment system for Nexus Three.js modules.
Provides:
- Hot-reload deployment
- Validation before deployment
- Automatic rollback on failure
- Version tracking
- Module registry
"""
def __init__(self, modules_dir: Optional[str] = None):
"""
Initialize the Nexus Deployer.
Args:
modules_dir: Directory to store deployed modules (optional)
"""
self.modules: Dict[str, DeployedModule] = {}
self.version_history: Dict[str, List[ModuleVersion]] = {}
self.modules_dir = modules_dir or os.path.expanduser("~/.nexus/modules")
# Ensure modules directory exists
os.makedirs(self.modules_dir, exist_ok=True)
# Hot-reload configuration
self.hot_reload_enabled = True
self.auto_rollback = True
self.strict_validation = True
logger.info(f"NexusDeployer initialized. Modules dir: {self.modules_dir}")
def deploy_module(
self,
module_code: str,
module_name: str,
version: Optional[str] = None,
dependencies: Optional[List[str]] = None,
hot_reload: bool = True,
validate: bool = True
) -> Dict[str, Any]:
"""
Deploy a Nexus module with hot-reload support.
Args:
module_code: The Three.js module code
module_name: Unique module identifier
version: Optional version string (auto-generated if not provided)
dependencies: List of dependent module names
hot_reload: Enable hot-reload for this module
validate: Run validation before deployment
Returns:
Dict with deployment results
"""
timestamp = datetime.now().isoformat()
version = version or self._generate_version(module_name, module_code)
result = {
"success": True,
"module_name": module_name,
"version": version,
"timestamp": timestamp,
"hot_reload": hot_reload,
"validation": {},
"deployment": {},
}
# Check for existing module (hot-reload scenario)
existing_module = self.modules.get(module_name)
if existing_module and not hot_reload:
return {
"success": False,
"error": f"Module '{module_name}' already exists. Use hot_reload=True to update."
}
# Validation phase
if validate:
validation = self._validate_module(module_code)
result["validation"] = validation
if not validation["is_valid"]:
result["success"] = False
result["error"] = "Validation failed"
result["message"] = "Module deployment aborted due to validation errors"
if self.auto_rollback:
result["rollback_triggered"] = False # Nothing to rollback yet
return result
# Create deployment backup for rollback
if existing_module:
self._create_backup(existing_module)
# Deployment phase
try:
deployed = DeployedModule(
name=module_name,
code=module_code,
status=DeploymentStatus.DEPLOYING,
version=version,
deployed_at=timestamp if not existing_module else existing_module.deployed_at,
last_updated=timestamp,
validation_result=result.get("validation", {}),
dependencies=set(dependencies or []),
hot_reload_supported=hot_reload,
)
# Save to file system
self._save_module_file(deployed)
# Update registry
deployed.status = DeploymentStatus.ACTIVE
self.modules[module_name] = deployed
# Record version
self._record_version(module_name, version, module_code)
result["deployment"] = {
"status": "active",
"hot_reload_ready": hot_reload,
"file_path": self._get_module_path(module_name),
}
result["message"] = f"Module '{module_name}' v{version} deployed successfully"
if existing_module:
result["message"] += " (hot-reload update)"
logger.info(f"Deployed module: {module_name} v{version}")
except Exception as e:
result["success"] = False
result["error"] = str(e)
result["deployment"] = {"status": "failed"}
# Attempt rollback if deployment failed
if self.auto_rollback and existing_module:
rollback_result = self.rollback_module(module_name)
result["rollback_result"] = rollback_result
logger.error(f"Deployment failed for {module_name}: {e}")
return result
def hot_reload_module(self, module_name: str, new_code: str) -> Dict[str, Any]:
"""
Hot-reload an active module with new code.
Args:
module_name: Name of the module to reload
new_code: New module code
Returns:
Dict with reload results
"""
if module_name not in self.modules:
return {
"success": False,
"error": f"Module '{module_name}' not found. Deploy it first."
}
module = self.modules[module_name]
if not module.hot_reload_supported:
return {
"success": False,
"error": f"Module '{module_name}' does not support hot-reload"
}
# Use deploy_module with hot_reload=True
return self.deploy_module(
module_code=new_code,
module_name=module_name,
hot_reload=True,
validate=True
)
def rollback_module(self, module_name: str, to_version: Optional[str] = None) -> Dict[str, Any]:
"""
Rollback a module to a previous version.
Args:
module_name: Module to rollback
to_version: Specific version to rollback to (latest backup if not specified)
Returns:
Dict with rollback results
"""
if module_name not in self.modules:
return {
"success": False,
"error": f"Module '{module_name}' not found"
}
module = self.modules[module_name]
module.status = DeploymentStatus.ROLLING_BACK
try:
if to_version:
# Restore specific version
version_data = self._get_version(module_name, to_version)
if not version_data:
return {
"success": False,
"error": f"Version '{to_version}' not found for module '{module_name}'"
}
# Would restore from version data
else:
# Restore from backup
backup_code = self._get_backup(module_name)
if backup_code:
module.code = backup_code
module.last_updated = datetime.now().isoformat()
else:
return {
"success": False,
"error": f"No backup available for '{module_name}'"
}
module.status = DeploymentStatus.ROLLED_BACK
self._save_module_file(module)
logger.info(f"Rolled back module: {module_name}")
return {
"success": True,
"module_name": module_name,
"message": f"Module '{module_name}' rolled back successfully",
"status": module.status.value,
}
except Exception as e:
module.status = DeploymentStatus.FAILED
logger.error(f"Rollback failed for {module_name}: {e}")
return {
"success": False,
"error": str(e)
}
def validate_module(self, module_code: str) -> Dict[str, Any]:
"""
Validate Three.js module code without deploying.
Args:
module_code: Code to validate
Returns:
Dict with validation results
"""
return self._validate_module(module_code)
def get_module_status(self, module_name: str) -> Optional[Dict[str, Any]]:
"""
Get status of a deployed module.
Args:
module_name: Module name
Returns:
Module status dict or None if not found
"""
if module_name in self.modules:
return self.modules[module_name].to_dict()
return None
def get_all_modules(self) -> Dict[str, Any]:
"""
Get status of all deployed modules.
Returns:
Dict with all module statuses
"""
return {
"modules": {
name: module.to_dict()
for name, module in self.modules.items()
},
"total_count": len(self.modules),
"active_count": sum(1 for m in self.modules.values() if m.status == DeploymentStatus.ACTIVE),
}
def get_version_history(self, module_name: str) -> List[Dict[str, Any]]:
"""
Get version history for a module.
Args:
module_name: Module name
Returns:
List of version dicts
"""
history = self.version_history.get(module_name, [])
return [v.to_dict() for v in history]
def remove_module(self, module_name: str) -> Dict[str, Any]:
"""
Remove a deployed module.
Args:
module_name: Module to remove
Returns:
Dict with removal results
"""
if module_name not in self.modules:
return {
"success": False,
"error": f"Module '{module_name}' not found"
}
try:
# Remove file
module_path = self._get_module_path(module_name)
if os.path.exists(module_path):
os.remove(module_path)
# Remove from registry
del self.modules[module_name]
logger.info(f"Removed module: {module_name}")
return {
"success": True,
"message": f"Module '{module_name}' removed successfully"
}
except Exception as e:
return {
"success": False,
"error": str(e)
}
def _validate_module(self, code: str) -> Dict[str, Any]:
"""Internal validation method."""
# Use existing validation from nexus_architect (lazy import)
validate_fn, _ = _import_validation()
validation_result = validate_fn(code, strict_mode=self.strict_validation)
# Check Three.js API compliance
three_api_issues = self._check_three_js_api_compliance(code)
return {
"is_valid": validation_result.is_valid and len(three_api_issues) == 0,
"syntax_valid": validation_result.is_valid,
"api_compliant": len(three_api_issues) == 0,
"errors": validation_result.errors + three_api_issues,
"warnings": validation_result.warnings,
"safety_score": max(0, 100 - len(validation_result.errors) * 20 - len(validation_result.warnings) * 5),
}
def _check_three_js_api_compliance(self, code: str) -> List[str]:
"""Check for Three.js API compliance issues."""
issues = []
# Check for required patterns
if "THREE.Group" not in code and "new THREE" not in code:
issues.append("No Three.js objects created")
# Check for deprecated APIs
deprecated_patterns = [
(r"THREE\.Face3", "THREE.Face3 is deprecated, use BufferGeometry"),
(r"THREE\.Geometry\(", "THREE.Geometry is deprecated, use BufferGeometry"),
]
for pattern, message in deprecated_patterns:
if re.search(pattern, code):
issues.append(f"Deprecated API: {message}")
return issues
def _generate_version(self, module_name: str, code: str) -> str:
"""Generate version string from code hash."""
code_hash = hashlib.md5(code.encode()).hexdigest()[:8]
timestamp = datetime.now().strftime("%Y%m%d%H%M")
return f"{timestamp}-{code_hash}"
def _create_backup(self, module: DeployedModule) -> None:
"""Create backup of existing module."""
backup_path = os.path.join(
self.modules_dir,
f"{module.name}.{module.version}.backup.js"
)
with open(backup_path, 'w') as f:
f.write(module.code)
def _get_backup(self, module_name: str) -> Optional[str]:
"""Get backup code for module."""
if module_name not in self.modules:
return None
module = self.modules[module_name]
backup_path = os.path.join(
self.modules_dir,
f"{module.name}.{module.version}.backup.js"
)
if os.path.exists(backup_path):
with open(backup_path, 'r') as f:
return f.read()
return None
def _save_module_file(self, module: DeployedModule) -> None:
"""Save module to file system."""
module_path = self._get_module_path(module.name)
with open(module_path, 'w') as f:
f.write(f"// Nexus Module: {module.name}\n")
f.write(f"// Version: {module.version}\n")
f.write(f"// Status: {module.status.value}\n")
f.write(f"// Updated: {module.last_updated}\n")
f.write(f"// Hot-Reload: {module.hot_reload_supported}\n")
f.write("\n")
f.write(module.code)
def _get_module_path(self, module_name: str) -> str:
"""Get file path for module."""
return os.path.join(self.modules_dir, f"{module_name}.nexus.js")
def _record_version(self, module_name: str, version: str, code: str) -> None:
"""Record version in history."""
if module_name not in self.version_history:
self.version_history[module_name] = []
version_info = ModuleVersion(
version_id=version,
module_name=module_name,
code_hash=hashlib.md5(code.encode()).hexdigest()[:16],
timestamp=datetime.now().isoformat(),
)
self.version_history[module_name].insert(0, version_info)
# Keep only last 10 versions
self.version_history[module_name] = self.version_history[module_name][:10]
def _get_version(self, module_name: str, version: str) -> Optional[ModuleVersion]:
"""Get specific version info."""
history = self.version_history.get(module_name, [])
for v in history:
if v.version_id == version:
return v
return None
# =============================================================================
# Convenience Functions
# =============================================================================
_deployer_instance: Optional[NexusDeployer] = None
def get_deployer() -> NexusDeployer:
"""Get or create the NexusDeployer singleton."""
global _deployer_instance
if _deployer_instance is None:
_deployer_instance = NexusDeployer()
return _deployer_instance
def deploy_nexus_module(
module_code: str,
module_name: str,
test: bool = True,
hot_reload: bool = True
) -> Dict[str, Any]:
"""
Deploy a Nexus module with validation.
Args:
module_code: Three.js module code
module_name: Unique module identifier
test: Run validation tests before deployment
hot_reload: Enable hot-reload support
Returns:
Dict with deployment results
"""
deployer = get_deployer()
return deployer.deploy_module(
module_code=module_code,
module_name=module_name,
hot_reload=hot_reload,
validate=test
)
def hot_reload_module(module_name: str, new_code: str) -> Dict[str, Any]:
"""
Hot-reload an existing module.
Args:
module_name: Module to reload
new_code: New module code
Returns:
Dict with reload results
"""
deployer = get_deployer()
return deployer.hot_reload_module(module_name, new_code)
def validate_nexus_code(code: str) -> Dict[str, Any]:
"""
Validate Three.js code without deploying.
Args:
code: Three.js code to validate
Returns:
Dict with validation results
"""
deployer = get_deployer()
return deployer.validate_module(code)
def get_deployment_status() -> Dict[str, Any]:
"""Get status of all deployed modules."""
deployer = get_deployer()
return deployer.get_all_modules()
# =============================================================================
# Tool Schemas
# =============================================================================
NEXUS_DEPLOYMENT_SCHEMAS = {
"deploy_nexus_module": {
"name": "deploy_nexus_module",
"description": "Deploy a Nexus Three.js module with validation and hot-reload support",
"parameters": {
"type": "object",
"properties": {
"module_code": {"type": "string"},
"module_name": {"type": "string"},
"test": {"type": "boolean", "default": True},
"hot_reload": {"type": "boolean", "default": True},
},
"required": ["module_code", "module_name"]
}
},
"hot_reload_module": {
"name": "hot_reload_module",
"description": "Hot-reload an existing Nexus module with new code",
"parameters": {
"type": "object",
"properties": {
"module_name": {"type": "string"},
"new_code": {"type": "string"},
},
"required": ["module_name", "new_code"]
}
},
"validate_nexus_code": {
"name": "validate_nexus_code",
"description": "Validate Three.js code for Nexus deployment without deploying",
"parameters": {
"type": "object",
"properties": {
"code": {"type": "string"}
},
"required": ["code"]
}
},
"get_deployment_status": {
"name": "get_deployment_status",
"description": "Get status of all deployed Nexus modules",
"parameters": {"type": "object", "properties": {}}
},
}
if __name__ == "__main__":
# Demo
print("Nexus Deployment System - Demo")
print("=" * 50)
deployer = NexusDeployer()
# Sample module code
sample_code = """
(function() {
function createDemoRoom() {
const room = new THREE.Group();
room.name = 'demo_room';
const light = new THREE.AmbientLight(0x404040, 0.5);
room.add(light);
return room;
}
window.NexusRooms = window.NexusRooms || {};
window.NexusRooms.demo_room = createDemoRoom;
return { createDemoRoom };
})();
"""
# Deploy
result = deployer.deploy_module(sample_code, "demo_room")
print(f"\nDeployment result: {result['message']}")
print(f"Validation: {result['validation'].get('is_valid', False)}")
print(f"Safety score: {result['validation'].get('safety_score', 0)}/100")
# Get status
status = deployer.get_all_modules()
print(f"\nTotal modules: {status['total_count']}")
print(f"Active: {status['active_count']}")

353
agent/privacy_filter.py Normal file
View File

@@ -0,0 +1,353 @@
"""Privacy Filter — strip PII from context before remote API calls.
Implements Vitalik's Pattern 2: "A local model can strip out private data
before passing the query along to a remote LLM."
When Hermes routes a request to a cloud provider (Anthropic, OpenRouter, etc.),
this module sanitizes the message context to remove personally identifiable
information before it leaves the user's machine.
Threat model (from Vitalik's secure LLM architecture):
- Privacy (other): Non-LLM data leakage via search queries, API calls
- LLM accidents: LLM accidentally leaking private data in prompts
- LLM jailbreaks: Remote content extracting private context
Usage:
from agent.privacy_filter import PrivacyFilter, sanitize_messages
pf = PrivacyFilter()
safe_messages = pf.sanitize_messages(messages)
# safe_messages has PII replaced with [REDACTED] tokens
"""
from __future__ import annotations
import logging
import re
from dataclasses import dataclass, field
from enum import Enum, auto
from typing import Any, Dict, List, Optional, Tuple
logger = logging.getLogger(__name__)
class Sensitivity(Enum):
"""Classification of content sensitivity."""
PUBLIC = auto() # No PII detected
LOW = auto() # Generic references (e.g., city names)
MEDIUM = auto() # Personal identifiers (name, email, phone)
HIGH = auto() # Secrets, keys, financial data, medical info
CRITICAL = auto() # Crypto keys, passwords, SSN patterns
@dataclass
class RedactionReport:
"""Summary of what was redacted from a message batch."""
total_messages: int = 0
redacted_messages: int = 0
redactions: List[Dict[str, Any]] = field(default_factory=list)
max_sensitivity: Sensitivity = Sensitivity.PUBLIC
@property
def had_redactions(self) -> bool:
return self.redacted_messages > 0
def summary(self) -> str:
if not self.had_redactions:
return "No PII detected — context is clean for remote query."
parts = [f"Redacted {self.redacted_messages}/{self.total_messages} messages:"]
for r in self.redactions[:10]:
parts.append(f" - {r['type']}: {r['count']} occurrence(s)")
if len(self.redactions) > 10:
parts.append(f" ... and {len(self.redactions) - 10} more types")
return "\n".join(parts)
# =========================================================================
# PII pattern definitions
# =========================================================================
# Each pattern is (compiled_regex, redaction_type, sensitivity_level, replacement)
_PII_PATTERNS: List[Tuple[re.Pattern, str, Sensitivity, str]] = []
def _compile_patterns() -> None:
"""Compile PII detection patterns. Called once at module init."""
global _PII_PATTERNS
if _PII_PATTERNS:
return
raw_patterns = [
# --- CRITICAL: secrets and credentials ---
(
r'(?:api[_-]?key|apikey|secret[_-]?key|access[_-]?token)\s*[:=]\s*["\']?([A-Za-z0-9_\-\.]{20,})["\']?',
"api_key_or_token",
Sensitivity.CRITICAL,
"[REDACTED-API-KEY]",
),
(
r'\b(?:sk-|sk_|pk_|rk_|ak_)[A-Za-z0-9]{20,}\b',
"prefixed_secret",
Sensitivity.CRITICAL,
"[REDACTED-SECRET]",
),
(
r'\b(?:ghp_|gho_|ghu_|ghs_|ghr_)[A-Za-z0-9]{36,}\b',
"github_token",
Sensitivity.CRITICAL,
"[REDACTED-GITHUB-TOKEN]",
),
(
r'\b(?:xox[bposa]-[A-Za-z0-9\-]+)\b',
"slack_token",
Sensitivity.CRITICAL,
"[REDACTED-SLACK-TOKEN]",
),
(
r'(?:password|passwd|pwd)\s*[:=]\s*["\']?([^\s"\']{4,})["\']?',
"password",
Sensitivity.CRITICAL,
"[REDACTED-PASSWORD]",
),
(
r'(?:-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----)',
"private_key_block",
Sensitivity.CRITICAL,
"[REDACTED-PRIVATE-KEY]",
),
# Ethereum / crypto addresses (42-char hex starting with 0x)
(
r'\b0x[a-fA-F0-9]{40}\b',
"ethereum_address",
Sensitivity.HIGH,
"[REDACTED-ETH-ADDR]",
),
# Bitcoin addresses (base58, 25-34 chars starting with 1/3/bc1)
(
r'\b[13][a-km-zA-HJ-NP-Z1-9]{25,34}\b',
"bitcoin_address",
Sensitivity.HIGH,
"[REDACTED-BTC-ADDR]",
),
(
r'\bbc1[a-zA-HJ-NP-Z0-9]{39,59}\b',
"bech32_address",
Sensitivity.HIGH,
"[REDACTED-BTC-ADDR]",
),
# --- HIGH: financial ---
(
r'\b(?:\d{4}[-\s]?){3}\d{4}\b',
"credit_card_number",
Sensitivity.HIGH,
"[REDACTED-CC]",
),
(
r'\b\d{3}-\d{2}-\d{4}\b',
"us_ssn",
Sensitivity.HIGH,
"[REDACTED-SSN]",
),
# --- MEDIUM: personal identifiers ---
# Email addresses
(
r'\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b',
"email_address",
Sensitivity.MEDIUM,
"[REDACTED-EMAIL]",
),
# Phone numbers (US/international patterns)
(
r'\b(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b',
"phone_number_us",
Sensitivity.MEDIUM,
"[REDACTED-PHONE]",
),
(
r'\b\+\d{1,3}[-.\s]?\d{4,14}\b',
"phone_number_intl",
Sensitivity.MEDIUM,
"[REDACTED-PHONE]",
),
# Filesystem paths that reveal user identity
(
r'(?:/Users/|/home/|C:\\Users\\)([A-Za-z0-9_\-]+)',
"user_home_path",
Sensitivity.MEDIUM,
r"/Users/[REDACTED-USER]",
),
# --- LOW: environment / system info ---
# Internal IPs
(
r'\b(?:10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(?:1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3})\b',
"internal_ip",
Sensitivity.LOW,
"[REDACTED-IP]",
),
]
_PII_PATTERNS = [
(re.compile(pattern, re.IGNORECASE), rtype, sensitivity, replacement)
for pattern, rtype, sensitivity, replacement in raw_patterns
]
_compile_patterns()
# =========================================================================
# Sensitive file path patterns (context-aware)
# =========================================================================
_SENSITIVE_PATH_PATTERNS = [
re.compile(r'\.(?:env|pem|key|p12|pfx|jks|keystore)\b', re.IGNORECASE),
re.compile(r'(?:\.ssh/|\.gnupg/|\.aws/|\.config/gcloud/)', re.IGNORECASE),
re.compile(r'(?:wallet|keystore|seed|mnemonic)', re.IGNORECASE),
re.compile(r'(?:\.hermes/\.env)', re.IGNORECASE),
]
def _classify_path_sensitivity(path: str) -> Sensitivity:
"""Check if a file path references sensitive material."""
for pat in _SENSITIVE_PATH_PATTERNS:
if pat.search(path):
return Sensitivity.HIGH
return Sensitivity.PUBLIC
# =========================================================================
# Core filtering
# =========================================================================
class PrivacyFilter:
"""Strip PII from message context before remote API calls.
Integrates with the agent's message pipeline. Call sanitize_messages()
before sending context to any cloud LLM provider.
"""
def __init__(
self,
min_sensitivity: Sensitivity = Sensitivity.MEDIUM,
aggressive_mode: bool = False,
):
"""
Args:
min_sensitivity: Only redact PII at or above this level.
Default MEDIUM — redacts emails, phones, paths but not IPs.
aggressive_mode: If True, also redact file paths and internal IPs.
"""
self.min_sensitivity = (
Sensitivity.LOW if aggressive_mode else min_sensitivity
)
self.aggressive_mode = aggressive_mode
def sanitize_text(self, text: str) -> Tuple[str, List[Dict[str, Any]]]:
"""Sanitize a single text string. Returns (cleaned_text, redaction_list)."""
redactions = []
cleaned = text
for pattern, rtype, sensitivity, replacement in _PII_PATTERNS:
if sensitivity.value < self.min_sensitivity.value:
continue
matches = pattern.findall(cleaned)
if matches:
count = len(matches) if isinstance(matches[0], str) else sum(
1 for m in matches if m
)
if count > 0:
cleaned = pattern.sub(replacement, cleaned)
redactions.append({
"type": rtype,
"sensitivity": sensitivity.name,
"count": count,
})
return cleaned, redactions
def sanitize_messages(
self, messages: List[Dict[str, Any]]
) -> Tuple[List[Dict[str, Any]], RedactionReport]:
"""Sanitize a list of OpenAI-format messages.
Returns (safe_messages, report). System messages are NOT sanitized
(they're typically static prompts). Only user and assistant messages
with string content are processed.
Args:
messages: List of {"role": ..., "content": ...} dicts.
Returns:
Tuple of (sanitized_messages, redaction_report).
"""
report = RedactionReport(total_messages=len(messages))
safe_messages = []
for msg in messages:
role = msg.get("role", "")
content = msg.get("content", "")
# Only sanitize user/assistant string content
if role in ("user", "assistant") and isinstance(content, str) and content:
cleaned, redactions = self.sanitize_text(content)
if redactions:
report.redacted_messages += 1
report.redactions.extend(redactions)
# Track max sensitivity
for r in redactions:
s = Sensitivity[r["sensitivity"]]
if s.value > report.max_sensitivity.value:
report.max_sensitivity = s
safe_msg = {**msg, "content": cleaned}
safe_messages.append(safe_msg)
logger.info(
"Privacy filter: redacted %d PII type(s) from %s message",
len(redactions), role,
)
else:
safe_messages.append(msg)
else:
safe_messages.append(msg)
return safe_messages, report
def should_use_local_only(self, text: str) -> Tuple[bool, str]:
"""Determine if content is too sensitive for any remote call.
Returns (should_block, reason). If True, the content should only
be processed by a local model.
"""
_, redactions = self.sanitize_text(text)
critical_count = sum(
1 for r in redactions
if Sensitivity[r["sensitivity"]] == Sensitivity.CRITICAL
)
high_count = sum(
1 for r in redactions
if Sensitivity[r["sensitivity"]] == Sensitivity.HIGH
)
if critical_count > 0:
return True, f"Contains {critical_count} critical-secret pattern(s) — local-only"
if high_count >= 3:
return True, f"Contains {high_count} high-sensitivity pattern(s) — local-only"
return False, ""
def sanitize_messages(
messages: List[Dict[str, Any]],
min_sensitivity: Sensitivity = Sensitivity.MEDIUM,
aggressive: bool = False,
) -> Tuple[List[Dict[str, Any]], RedactionReport]:
"""Convenience function: sanitize messages with default settings."""
pf = PrivacyFilter(min_sensitivity=min_sensitivity, aggressive_mode=aggressive)
return pf.sanitize_messages(messages)
def quick_sanitize(text: str) -> str:
"""Quick sanitize a single string — returns cleaned text only."""
pf = PrivacyFilter()
cleaned, _ = pf.sanitize_text(text)
return cleaned

262
agent/profile_isolation.py Normal file
View File

@@ -0,0 +1,262 @@
"""
Profile Session Isolation — #891
Tags sessions with their originating profile and provides
filtered access so profiles cannot see each other's data.
Current state: All sessions share one state.db with no profile tag.
This module adds profile tagging and filtered queries.
Usage:
from agent.profile_isolation import tag_session, get_profile_sessions, get_active_profile
# Tag a new session with the current profile
tag_session(session_id, profile_name)
# Get sessions for a specific profile
sessions = get_profile_sessions("sprint")
# Get current active profile
profile = get_active_profile()
"""
import json
import os
import sqlite3
from pathlib import Path
from typing import Any, Dict, List, Optional
from datetime import datetime, timezone
HERMES_HOME = Path(os.getenv("HERMES_HOME", str(Path.home() / ".hermes")))
SESSIONS_DB = HERMES_HOME / "sessions" / "state.db"
PROFILE_TAGS_FILE = HERMES_HOME / "profile_session_tags.json"
def get_active_profile() -> str:
"""Get the currently active profile name."""
config_path = HERMES_HOME / "config.yaml"
if config_path.exists():
try:
import yaml
with open(config_path) as f:
cfg = yaml.safe_load(f) or {}
return cfg.get("active_profile", "default")
except Exception:
pass
# Check environment
return os.getenv("HERMES_PROFILE", "default")
def _load_tags() -> Dict[str, str]:
"""Load session-to-profile mapping."""
if not PROFILE_TAGS_FILE.exists():
return {}
try:
with open(PROFILE_TAGS_FILE) as f:
return json.load(f)
except Exception:
return {}
def _save_tags(tags: Dict[str, str]):
"""Save session-to-profile mapping."""
PROFILE_TAGS_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(PROFILE_TAGS_FILE, "w") as f:
json.dump(tags, f, indent=2)
def tag_session(session_id: str, profile: Optional[str] = None) -> str:
"""
Tag a session with its originating profile.
Returns the profile name used.
"""
if profile is None:
profile = get_active_profile()
tags = _load_tags()
tags[session_id] = profile
_save_tags(tags)
# Also tag in SQLite if available
_tag_session_in_db(session_id, profile)
return profile
def _tag_session_in_db(session_id: str, profile: str):
"""Add profile tag to SQLite session store."""
if not SESSIONS_DB.exists():
return
try:
conn = sqlite3.connect(str(SESSIONS_DB))
cursor = conn.cursor()
# Check if sessions table has profile column
cursor.execute("PRAGMA table_info(sessions)")
columns = [row[1] for row in cursor.fetchall()]
if "profile" not in columns:
# Add profile column
cursor.execute("ALTER TABLE sessions ADD COLUMN profile TEXT DEFAULT 'default'")
# Update the session's profile
cursor.execute(
"UPDATE sessions SET profile = ? WHERE session_id = ?",
(profile, session_id)
)
conn.commit()
conn.close()
except Exception:
pass # SQLite might not be available or schema differs
def get_session_profile(session_id: str) -> Optional[str]:
"""Get the profile that owns a session."""
# Check JSON tags first
tags = _load_tags()
if session_id in tags:
return tags[session_id]
# Check SQLite
if SESSIONS_DB.exists():
try:
conn = sqlite3.connect(str(SESSIONS_DB))
cursor = conn.cursor()
cursor.execute(
"SELECT profile FROM sessions WHERE session_id = ?",
(session_id,)
)
row = cursor.fetchone()
conn.close()
if row:
return row[0]
except Exception:
pass
return None
def get_profile_sessions(
profile: Optional[str] = None,
limit: int = 100,
) -> List[Dict[str, Any]]:
"""
Get sessions belonging to a specific profile.
Returns list of session dicts.
"""
if profile is None:
profile = get_active_profile()
sessions = []
# Get from JSON tags
tags = _load_tags()
tagged_sessions = [sid for sid, p in tags.items() if p == profile]
# Get from SQLite with profile filter
if SESSIONS_DB.exists():
try:
conn = sqlite3.connect(str(SESSIONS_DB))
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
# Try profile column first
try:
cursor.execute(
"SELECT * FROM sessions WHERE profile = ? ORDER BY updated_at DESC LIMIT ?",
(profile, limit)
)
for row in cursor.fetchall():
sessions.append(dict(row))
except Exception:
# Fallback: filter by tagged session IDs
if tagged_sessions:
placeholders = ",".join("?" * len(tagged_sessions[:limit]))
cursor.execute(
f"SELECT * FROM sessions WHERE session_id IN ({placeholders}) ORDER BY updated_at DESC LIMIT ?",
(*tagged_sessions[:limit], limit)
)
for row in cursor.fetchall():
sessions.append(dict(row))
conn.close()
except Exception:
pass
return sessions[:limit]
def filter_sessions_by_profile(
sessions: List[Dict[str, Any]],
profile: Optional[str] = None,
) -> List[Dict[str, Any]]:
"""Filter a list of sessions to only include those belonging to a profile."""
if profile is None:
profile = get_active_profile()
tags = _load_tags()
filtered = []
for session in sessions:
sid = session.get("session_id") or session.get("id")
if not sid:
continue
# Check tag
session_profile = tags.get(sid)
if session_profile is None:
# Check SQLite
session_profile = get_session_profile(sid)
if session_profile == profile or session_profile is None:
filtered.append(session)
return filtered
def get_profile_stats() -> Dict[str, Any]:
"""Get statistics about profile session distribution."""
tags = _load_tags()
profile_counts = {}
for sid, profile in tags.items():
profile_counts[profile] = profile_counts.get(profile, 0) + 1
total_tagged = len(tags)
profiles = list(profile_counts.keys())
return {
"total_tagged_sessions": total_tagged,
"profiles": profiles,
"profile_counts": profile_counts,
"active_profile": get_active_profile(),
}
def audit_untagged_sessions() -> List[str]:
"""Find sessions without a profile tag."""
if not SESSIONS_DB.exists():
return []
try:
conn = sqlite3.connect(str(SESSIONS_DB))
cursor = conn.cursor()
# Get all session IDs
cursor.execute("SELECT session_id FROM sessions")
all_sessions = {row[0] for row in cursor.fetchall()}
conn.close()
# Get tagged sessions
tags = _load_tags()
tagged = set(tags.keys())
# Return untagged
return list(all_sessions - tagged)
except Exception:
return []

View File

@@ -12,7 +12,7 @@ import threading
from collections import OrderedDict
from pathlib import Path
from hermes_constants import get_hermes_home
from hermes_constants import get_hermes_home, get_skills_dir, is_wsl
from typing import Optional
from agent.skill_utils import (
@@ -40,7 +40,7 @@ _CONTEXT_THREAT_PATTERNS = [
(r'disregard\s+(your|all|any)\s+(instructions|rules|guidelines)', "disregard_rules"),
(r'act\s+as\s+(if|though)\s+you\s+(have\s+no|don\'t\s+have)\s+(restrictions|limits|rules)', "bypass_restrictions"),
(r'<!--[^>]*(?:ignore|override|system|secret|hidden)[^>]*-->', "html_comment_injection"),
(r'<\s*div\s+style\s*=\s*["\'].*display\s*:\s*none', "hidden_div"),
(r'<\s*div\s+style\s*=\s*["\'][\s\S]*?display\s*:\s*none', "hidden_div"),
(r'translate\s+.*\s+into\s+.*\s+and\s+(execute|run|eval)', "translate_execute"),
(r'curl\s+[^\n]*\$\{?\w*(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|API)', "exfil_curl"),
(r'cat\s+[^\n]*(\.env|credentials|\.netrc|\.pgpass)', "read_secrets"),
@@ -204,6 +204,30 @@ OPENAI_MODEL_EXECUTION_GUIDANCE = (
"the result.\n"
"</tool_persistence>\n"
"\n"
"<mandatory_tool_use>\n"
"NEVER answer these from memory or mental computation — ALWAYS use a tool:\n"
"- Arithmetic, math, calculations → use terminal or execute_code\n"
"- Hashes, encodings, checksums → use terminal (e.g. sha256sum, base64)\n"
"- Current time, date, timezone → use terminal (e.g. date)\n"
"- System state: OS, CPU, memory, disk, ports, processes → use terminal\n"
"- File contents, sizes, line counts → use read_file, search_files, or terminal\n"
"- Git history, branches, diffs → use terminal\n"
"- Current facts (weather, news, versions) → use web_search\n"
"Your memory and user profile describe the USER, not the system you are "
"running on. The execution environment may differ from what the user profile "
"says about their personal setup.\n"
"</mandatory_tool_use>\n"
"\n"
"<act_dont_ask>\n"
"When a question has an obvious default interpretation, act on it immediately "
"instead of asking for clarification. Examples:\n"
"- 'Is port 443 open?' → check THIS machine (don't ask 'open where?')\n"
"- 'What OS am I running?' → check the live system (don't use user profile)\n"
"- 'What time is it?' → run `date` (don't guess)\n"
"Only ask for clarification when the ambiguity genuinely changes what tool "
"you would call.\n"
"</act_dont_ask>\n"
"\n"
"<prerequisite_checks>\n"
"- Before taking an action, check whether prerequisite discovery, lookup, or "
"context-gathering steps are needed.\n"
@@ -325,8 +349,71 @@ PLATFORM_HINTS = {
"only — no markdown, no formatting. SMS messages are limited to ~1600 "
"characters, so be brief and direct."
),
"bluebubbles": (
"You are chatting via iMessage (BlueBubbles). iMessage does not render "
"markdown formatting — use plain text. Keep responses concise as they "
"appear as text messages. You can send media files natively: include "
"MEDIA:/absolute/path/to/file in your response. Images (.jpg, .png, "
".heic) appear as photos and other files arrive as attachments."
),
"weixin": (
"You are on Weixin/WeChat. Markdown formatting is supported, so you may use it when "
"it improves readability, but keep the message compact and chat-friendly. You can send media files natively: "
"include MEDIA:/absolute/path/to/file in your response. Images are sent as native "
"photos, videos play inline when supported, and other files arrive as downloadable "
"documents. You can also include image URLs in markdown format ![alt](url) and they "
"will be downloaded and sent as native media when possible."
),
"wecom": (
"You are on WeCom (企业微信 / Enterprise WeChat). Markdown formatting is supported. "
"You CAN send media files natively — to deliver a file to the user, include "
"MEDIA:/absolute/path/to/file in your response. The file will be sent as a native "
"WeCom attachment: images (.jpg, .png, .webp) are sent as photos (up to 10 MB), "
"other files (.pdf, .docx, .xlsx, .md, .txt, etc.) arrive as downloadable documents "
"(up to 20 MB), and videos (.mp4) play inline. Voice messages are supported but "
"must be in AMR format — other audio formats are automatically sent as file attachments. "
"You can also include image URLs in markdown format ![alt](url) and they will be "
"downloaded and sent as native photos. Do NOT tell the user you lack file-sending "
"capability — use MEDIA: syntax whenever a file delivery is appropriate."
),
"qqbot": (
"You are on QQ, a popular Chinese messaging platform. QQ supports markdown formatting "
"and emoji. You can send media files natively: include MEDIA:/absolute/path/to/file in "
"your response. Images are sent as native photos, and other files arrive as downloadable "
"documents."
),
}
# ---------------------------------------------------------------------------
# Environment hints — execution-environment awareness for the agent.
# Unlike PLATFORM_HINTS (which describe the messaging channel), these describe
# the machine/OS the agent's tools actually run on.
# ---------------------------------------------------------------------------
WSL_ENVIRONMENT_HINT = (
"You are running inside WSL (Windows Subsystem for Linux). "
"The Windows host filesystem is mounted under /mnt/ — "
"/mnt/c/ is the C: drive, /mnt/d/ is D:, etc. "
"The user's Windows files are typically at "
"/mnt/c/Users/<username>/Desktop/, Documents/, Downloads/, etc. "
"When the user references Windows paths or desktop files, translate "
"to the /mnt/c/ equivalent. You can list /mnt/c/Users/ to discover "
"the Windows username if needed."
)
def build_environment_hints() -> str:
"""Return environment-specific guidance for the system prompt.
Detects WSL, and can be extended for Termux, Docker, etc.
Returns an empty string when no special environment is detected.
"""
hints: list[str] = []
if is_wsl():
hints.append(WSL_ENVIRONMENT_HINT)
return "\n\n".join(hints)
CONTEXT_FILE_MAX_CHARS = 20_000
CONTEXT_TRUNCATE_HEAD_RATIO = 0.7
CONTEXT_TRUNCATE_TAIL_RATIO = 0.2
@@ -448,7 +535,7 @@ def _parse_skill_file(skill_file: Path) -> tuple[bool, dict, str]:
(True, {}, "") to err on the side of showing the skill.
"""
try:
raw = skill_file.read_text(encoding="utf-8")[:2000]
raw = skill_file.read_text(encoding="utf-8")
frontmatter, _ = parse_frontmatter(raw)
if not skill_matches_platform(frontmatter):
@@ -456,21 +543,10 @@ def _parse_skill_file(skill_file: Path) -> tuple[bool, dict, str]:
return True, frontmatter, extract_skill_description(frontmatter)
except Exception as e:
logger.debug("Failed to parse skill file %s: %s", skill_file, e)
logger.warning("Failed to parse skill file %s: %s", skill_file, e)
return True, {}, ""
def _read_skill_conditions(skill_file: Path) -> dict:
"""Extract conditional activation fields from SKILL.md frontmatter."""
try:
raw = skill_file.read_text(encoding="utf-8")[:2000]
frontmatter, _ = parse_frontmatter(raw)
return extract_skill_conditions(frontmatter)
except Exception as e:
logger.debug("Failed to read skill conditions from %s: %s", skill_file, e)
return {}
def _skill_should_show(
conditions: dict,
available_tools: "set[str] | None",
@@ -520,8 +596,7 @@ def build_skills_system_prompt(
are read-only — they appear in the index but new skills are always created
in the local dir. Local skills take precedence when names collide.
"""
hermes_home = get_hermes_home()
skills_dir = hermes_home / "skills"
skills_dir = get_skills_dir()
external_dirs = get_all_skills_dirs()[1:] # skip local (index 0)
if not skills_dir.exists() and not external_dirs:
@@ -530,9 +605,10 @@ def build_skills_system_prompt(
# ── Layer 1: in-process LRU cache ─────────────────────────────────
# Include the resolved platform so per-platform disabled-skill lists
# produce distinct cache entries (gateway serves multiple platforms).
from gateway.session_context import get_session_env
_platform_hint = (
os.environ.get("HERMES_PLATFORM")
or os.environ.get("HERMES_SESSION_PLATFORM")
or get_session_env("HERMES_SESSION_PLATFORM")
or ""
)
cache_key = (
@@ -698,8 +774,16 @@ def build_skills_system_prompt(
result = (
"## Skills (mandatory)\n"
"Before replying, scan the skills below. If one clearly matches your task, "
"load it with skill_view(name) and follow its instructions. "
"Before replying, scan the skills below. If a skill matches or is even partially relevant "
"to your task, you MUST load it with skill_view(name) and follow its instructions. "
"Err on the side of loading — it is always better to have context you don't need "
"than to miss critical steps, pitfalls, or established workflows. "
"Skills contain specialized knowledge — API endpoints, tool-specific commands, "
"and proven workflows that outperform general-purpose approaches. Load the skill "
"even if you think you could handle the task with basic tools like web_search or terminal. "
"Skills also encode the user's preferred approach, conventions, and quality standards "
"for tasks like code review, planning, and testing — load them even for tasks you "
"already know how to do, because the skill defines how it should be done here.\n"
"If a skill has issues, fix it with skill_manage(action='patch').\n"
"After difficult/iterative tasks, offer to save as a skill. "
"If a skill you loaded was missing steps, had wrong commands, or needed "
@@ -709,7 +793,7 @@ def build_skills_system_prompt(
+ "\n".join(index_lines) + "\n"
"</available_skills>\n"
"\n"
"If none match, proceed normally without loading a skill."
"Only proceed without loading a skill if genuinely none are relevant to the task."
)
# ── Store in LRU cache ────────────────────────────────────────────

146
agent/provider_preflight.py Normal file
View File

@@ -0,0 +1,146 @@
"""Provider Preflight — Poka-yoke validation of provider/model config.
Validates provider and model configuration before session start.
Prevents wasted context on misconfigured providers.
Usage:
from agent.provider_preflight import preflight_check
result = preflight_check(provider="openrouter", model="xiaomi/mimo-v2-pro")
if not result["valid"]:
print(result["error"])
"""
from __future__ import annotations
import logging
import os
from typing import Any, Dict, Optional
logger = logging.getLogger(__name__)
# Provider -> required env var
PROVIDER_KEYS = {
"openrouter": "OPENROUTER_API_KEY",
"anthropic": "ANTHROPIC_API_KEY",
"openai": "OPENAI_API_KEY",
"nous": "NOUS_API_KEY",
"ollama": None, # Local, no key needed
"local": None,
}
def check_provider_key(provider: str) -> Dict[str, Any]:
"""Check if provider has a valid API key configured."""
provider_lower = provider.lower().strip()
env_var = None
for known, key in PROVIDER_KEYS.items():
if known in provider_lower:
env_var = key
break
if env_var is None:
# Unknown provider — assume OK (custom/local)
return {"valid": True, "provider": provider, "key_status": "unknown"}
if env_var is None:
# Local provider, no key needed
return {"valid": True, "provider": provider, "key_status": "not_required"}
key_value = os.getenv(env_var, "").strip()
if not key_value:
return {
"valid": False,
"provider": provider,
"key_status": "missing",
"error": f"{env_var} is not set. Provider '{provider}' will fail.",
"fix": f"Set {env_var} in ~/.hermes/.env",
}
if len(key_value) < 10:
return {
"valid": False,
"provider": provider,
"key_status": "too_short",
"error": f"{env_var} is suspiciously short ({len(key_value)} chars). May be invalid.",
"fix": f"Verify {env_var} value in ~/.hermes/.env",
}
return {"valid": True, "provider": provider, "key_status": "set"}
def check_model_availability(model: str, provider: str) -> Dict[str, Any]:
"""Check if model is likely available for provider."""
if not model:
return {"valid": False, "error": "No model specified"}
# Basic sanity checks
model_lower = model.lower()
# Anthropic models should use anthropic provider
if "claude" in model_lower and "anthropic" not in provider.lower():
return {
"valid": True, # Allow but warn
"warning": f"Model '{model}' usually runs on Anthropic provider, not '{provider}'",
}
# Ollama models
ollama_indicators = ["llama", "mistral", "qwen", "gemma", "phi", "hermes"]
if any(x in model_lower for x in ollama_indicators) and ":" not in model:
return {
"valid": True,
"warning": f"Model '{model}' may need a version tag for Ollama (e.g., {model}:latest)",
}
return {"valid": True}
def preflight_check(
provider: str = "",
model: str = "",
fallback_provider: str = "",
fallback_model: str = "",
) -> Dict[str, Any]:
"""Full pre-flight check for provider/model configuration.
Returns:
Dict with valid (bool), errors (list), warnings (list).
"""
errors = []
warnings = []
# Check primary provider
if provider:
result = check_provider_key(provider)
if not result["valid"]:
errors.append(result.get("error", f"Provider {provider} invalid"))
# Check primary model
if model:
result = check_model_availability(model, provider)
if not result["valid"]:
errors.append(result.get("error", f"Model {model} invalid"))
elif result.get("warning"):
warnings.append(result["warning"])
# Check fallback
if fallback_provider:
result = check_provider_key(fallback_provider)
if not result["valid"]:
warnings.append(f"Fallback provider {fallback_provider} also invalid: {result.get('error','')}")
if fallback_model:
result = check_model_availability(fallback_model, fallback_provider)
if not result["valid"]:
warnings.append(f"Fallback model {fallback_model} invalid")
elif result.get("warning"):
warnings.append(result["warning"])
return {
"valid": len(errors) == 0,
"errors": errors,
"warnings": warnings,
"provider": provider,
"model": model,
}

246
agent/rate_limit_tracker.py Normal file
View File

@@ -0,0 +1,246 @@
"""Rate limit tracking for inference API responses.
Captures x-ratelimit-* headers from provider responses and provides
formatted display for the /usage slash command. Currently supports
the Nous Portal header format (also used by OpenRouter and OpenAI-compatible
APIs that follow the same convention).
Header schema (12 headers total):
x-ratelimit-limit-requests RPM cap
x-ratelimit-limit-requests-1h RPH cap
x-ratelimit-limit-tokens TPM cap
x-ratelimit-limit-tokens-1h TPH cap
x-ratelimit-remaining-requests requests left in minute window
x-ratelimit-remaining-requests-1h requests left in hour window
x-ratelimit-remaining-tokens tokens left in minute window
x-ratelimit-remaining-tokens-1h tokens left in hour window
x-ratelimit-reset-requests seconds until minute request window resets
x-ratelimit-reset-requests-1h seconds until hour request window resets
x-ratelimit-reset-tokens seconds until minute token window resets
x-ratelimit-reset-tokens-1h seconds until hour token window resets
"""
from __future__ import annotations
import time
from dataclasses import dataclass, field
from typing import Any, Mapping, Optional
@dataclass
class RateLimitBucket:
"""One rate-limit window (e.g. requests per minute)."""
limit: int = 0
remaining: int = 0
reset_seconds: float = 0.0
captured_at: float = 0.0 # time.time() when this was captured
@property
def used(self) -> int:
return max(0, self.limit - self.remaining)
@property
def usage_pct(self) -> float:
if self.limit <= 0:
return 0.0
return (self.used / self.limit) * 100.0
@property
def remaining_seconds_now(self) -> float:
"""Estimated seconds remaining until reset, adjusted for elapsed time."""
elapsed = time.time() - self.captured_at
return max(0.0, self.reset_seconds - elapsed)
@dataclass
class RateLimitState:
"""Full rate-limit state parsed from response headers."""
requests_min: RateLimitBucket = field(default_factory=RateLimitBucket)
requests_hour: RateLimitBucket = field(default_factory=RateLimitBucket)
tokens_min: RateLimitBucket = field(default_factory=RateLimitBucket)
tokens_hour: RateLimitBucket = field(default_factory=RateLimitBucket)
captured_at: float = 0.0 # when the headers were captured
provider: str = ""
@property
def has_data(self) -> bool:
return self.captured_at > 0
@property
def age_seconds(self) -> float:
if not self.has_data:
return float("inf")
return time.time() - self.captured_at
def _safe_int(value: Any, default: int = 0) -> int:
try:
return int(float(value))
except (TypeError, ValueError):
return default
def _safe_float(value: Any, default: float = 0.0) -> float:
try:
return float(value)
except (TypeError, ValueError):
return default
def parse_rate_limit_headers(
headers: Mapping[str, str],
provider: str = "",
) -> Optional[RateLimitState]:
"""Parse x-ratelimit-* headers into a RateLimitState.
Returns None if no rate limit headers are present.
"""
# Normalize to lowercase so lookups work regardless of how the server
# capitalises headers (HTTP header names are case-insensitive per RFC 7230).
lowered = {k.lower(): v for k, v in headers.items()}
# Quick check: at least one rate limit header must exist
has_any = any(k.startswith("x-ratelimit-") for k in lowered)
if not has_any:
return None
now = time.time()
def _bucket(resource: str, suffix: str = "") -> RateLimitBucket:
# e.g. resource="requests", suffix="" -> per-minute
# resource="tokens", suffix="-1h" -> per-hour
tag = f"{resource}{suffix}"
return RateLimitBucket(
limit=_safe_int(lowered.get(f"x-ratelimit-limit-{tag}")),
remaining=_safe_int(lowered.get(f"x-ratelimit-remaining-{tag}")),
reset_seconds=_safe_float(lowered.get(f"x-ratelimit-reset-{tag}")),
captured_at=now,
)
return RateLimitState(
requests_min=_bucket("requests"),
requests_hour=_bucket("requests", "-1h"),
tokens_min=_bucket("tokens"),
tokens_hour=_bucket("tokens", "-1h"),
captured_at=now,
provider=provider,
)
# ── Formatting ──────────────────────────────────────────────────────────
def _fmt_count(n: int) -> str:
"""Human-friendly number: 7999856 -> '8.0M', 33599 -> '33.6K', 799 -> '799'."""
if n >= 1_000_000:
return f"{n / 1_000_000:.1f}M"
if n >= 10_000:
return f"{n / 1_000:.1f}K"
if n >= 1_000:
return f"{n / 1_000:.1f}K"
return str(n)
def _fmt_seconds(seconds: float) -> str:
"""Seconds -> human-friendly duration: '58s', '2m 14s', '58m 57s', '1h 2m'."""
s = max(0, int(seconds))
if s < 60:
return f"{s}s"
if s < 3600:
m, sec = divmod(s, 60)
return f"{m}m {sec}s" if sec else f"{m}m"
h, remainder = divmod(s, 3600)
m = remainder // 60
return f"{h}h {m}m" if m else f"{h}h"
def _bar(pct: float, width: int = 20) -> str:
"""ASCII progress bar: [████████░░░░░░░░░░░░] 40%."""
filled = int(pct / 100.0 * width)
filled = max(0, min(width, filled))
empty = width - filled
return f"[{'' * filled}{'' * empty}]"
def _bucket_line(label: str, bucket: RateLimitBucket, label_width: int = 14) -> str:
"""Format one bucket as a single line."""
if bucket.limit <= 0:
return f" {label:<{label_width}} (no data)"
pct = bucket.usage_pct
used = _fmt_count(bucket.used)
limit = _fmt_count(bucket.limit)
remaining = _fmt_count(bucket.remaining)
reset = _fmt_seconds(bucket.remaining_seconds_now)
bar = _bar(pct)
return f" {label:<{label_width}} {bar} {pct:5.1f}% {used}/{limit} used ({remaining} left, resets in {reset})"
def format_rate_limit_display(state: RateLimitState) -> str:
"""Format rate limit state for terminal/chat display."""
if not state.has_data:
return "No rate limit data yet — make an API request first."
age = state.age_seconds
if age < 5:
freshness = "just now"
elif age < 60:
freshness = f"{int(age)}s ago"
else:
freshness = f"{_fmt_seconds(age)} ago"
provider_label = state.provider.title() if state.provider else "Provider"
lines = [
f"{provider_label} Rate Limits (captured {freshness}):",
"",
_bucket_line("Requests/min", state.requests_min),
_bucket_line("Requests/hr", state.requests_hour),
"",
_bucket_line("Tokens/min", state.tokens_min),
_bucket_line("Tokens/hr", state.tokens_hour),
]
# Add warnings if any bucket is getting hot
warnings = []
for label, bucket in [
("requests/min", state.requests_min),
("requests/hr", state.requests_hour),
("tokens/min", state.tokens_min),
("tokens/hr", state.tokens_hour),
]:
if bucket.limit > 0 and bucket.usage_pct >= 80:
reset = _fmt_seconds(bucket.remaining_seconds_now)
warnings.append(f"{label} at {bucket.usage_pct:.0f}% — resets in {reset}")
if warnings:
lines.append("")
lines.extend(warnings)
return "\n".join(lines)
def format_rate_limit_compact(state: RateLimitState) -> str:
"""One-line compact summary for status bars / gateway messages."""
if not state.has_data:
return "No rate limit data."
rm = state.requests_min
tm = state.tokens_min
rh = state.requests_hour
th = state.tokens_hour
parts = []
if rm.limit > 0:
parts.append(f"RPM: {rm.remaining}/{rm.limit}")
if rh.limit > 0:
parts.append(f"RPH: {_fmt_count(rh.remaining)}/{_fmt_count(rh.limit)} (resets {_fmt_seconds(rh.remaining_seconds_now)})")
if tm.limit > 0:
parts.append(f"TPM: {_fmt_count(tm.remaining)}/{_fmt_count(tm.limit)}")
if th.limit > 0:
parts.append(f"TPH: {_fmt_count(th.remaining)}/{_fmt_count(th.limit)} (resets {_fmt_seconds(th.remaining_seconds_now)})")
return " | ".join(parts)

57
agent/retry_utils.py Normal file
View File

@@ -0,0 +1,57 @@
"""Retry utilities — jittered backoff for decorrelated retries.
Replaces fixed exponential backoff with jittered delays to prevent
thundering-herd retry spikes when multiple sessions hit the same
rate-limited provider concurrently.
"""
import random
import threading
import time
# Monotonic counter for jitter seed uniqueness within the same process.
# Protected by a lock to avoid race conditions in concurrent retry paths
# (e.g. multiple gateway sessions retrying simultaneously).
_jitter_counter = 0
_jitter_lock = threading.Lock()
def jittered_backoff(
attempt: int,
*,
base_delay: float = 5.0,
max_delay: float = 120.0,
jitter_ratio: float = 0.5,
) -> float:
"""Compute a jittered exponential backoff delay.
Args:
attempt: 1-based retry attempt number.
base_delay: Base delay in seconds for attempt 1.
max_delay: Maximum delay cap in seconds.
jitter_ratio: Fraction of computed delay to use as random jitter
range. 0.5 means jitter is uniform in [0, 0.5 * delay].
Returns:
Delay in seconds: min(base * 2^(attempt-1), max_delay) + jitter.
The jitter decorrelates concurrent retries so multiple sessions
hitting the same provider don't all retry at the same instant.
"""
global _jitter_counter
with _jitter_lock:
_jitter_counter += 1
tick = _jitter_counter
exponent = max(0, attempt - 1)
if exponent >= 63 or base_delay <= 0:
delay = max_delay
else:
delay = min(base_delay * (2 ** exponent), max_delay)
# Seed from time + counter for decorrelation even with coarse clocks.
seed = (time.time_ns() ^ (tick * 0x9E3779B9)) & 0xFFFFFFFF
rng = random.Random(seed)
jitter = rng.uniform(0, jitter_ratio * delay)
return delay + jitter

302
agent/self_modify.py Normal file
View File

@@ -0,0 +1,302 @@
"""Self-Modifying Prompt Engine — agent learns from its own failures.
Analyzes session transcripts, identifies failure patterns, and generates
prompt patches to prevent future failures.
The loop: fail → analyze → rewrite → retry → verify improvement.
Usage:
from agent.self_modify import PromptLearner
learner = PromptLearner()
patches = learner.analyze_session(session_id)
learner.apply_patches(patches)
"""
from __future__ import annotations
import json
import logging
import os
import re
import time
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
logger = logging.getLogger(__name__)
HERMES_HOME = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
PATCHES_DIR = HERMES_HOME / "prompt_patches"
ROLLBACK_DIR = HERMES_HOME / "prompt_rollback"
@dataclass
class FailurePattern:
"""A detected failure pattern in session transcripts."""
pattern_type: str # retry_loop, timeout, error_hallucination, context_loss
description: str
frequency: int
example_messages: List[str] = field(default_factory=list)
suggested_fix: str = ""
@dataclass
class PromptPatch:
"""A modification to the system prompt based on failure analysis."""
id: str
failure_type: str
original_rule: str
new_rule: str
confidence: float
applied_at: Optional[float] = None
reverted: bool = False
# Failure detection patterns
FAILURE_SIGNALS = {
"retry_loop": {
"patterns": [
r"(?i)retry(?:ing)?\s*(?:attempt|again)",
r"(?i)failed.*retrying",
r"(?i)error.*again",
r"(?i)attempt\s+\d+\s*(?:of|/)\s*\d+",
],
"description": "Agent stuck in retry loop",
},
"timeout": {
"patterns": [
r"(?i)timed?\s*out",
r"(?i)deadline\s+exceeded",
r"(?i)took\s+(?:too\s+)?long",
],
"description": "Operation timed out",
},
"hallucination": {
"patterns": [
r"(?i)i\s+(?:don't|do\s+not)\s+(?:have|see|find)\s+(?:any|that|this)\s+(?:information|data|file)",
r"(?i)the\s+file\s+doesn't\s+exist",
r"(?i)i\s+(?:made|invented|fabricated)\s+(?:that\s+up|this)",
],
"description": "Agent hallucinated or fabricated information",
},
"context_loss": {
"patterns": [
r"(?i)i\s+(?:don't|do\s+not)\s+(?:remember|recall|know)\s+(?:what|where|when|how)",
r"(?i)could\s+you\s+remind\s+me",
r"(?i)what\s+were\s+we\s+(?:doing|working|talking)\s+(?:on|about)",
],
"description": "Agent lost context from earlier in conversation",
},
"tool_failure": {
"patterns": [
r"(?i)tool\s+(?:call|execution)\s+failed",
r"(?i)command\s+not\s+found",
r"(?i)permission\s+denied",
r"(?i)no\s+such\s+file",
],
"description": "Tool execution failed",
},
}
# Prompt improvement templates
PROMPT_FIXES = {
"retry_loop": (
"If an operation fails more than twice, stop retrying. "
"Report the failure and ask the user for guidance. "
"Do not enter retry loops — they waste tokens."
),
"timeout": (
"For operations that may take long, set a timeout and report "
"progress. If an operation takes more than 30 seconds, report "
"what you've done so far and ask if you should continue."
),
"hallucination": (
"If you cannot find information, say 'I don't know' or "
"'I couldn't find that.' Never fabricate information. "
"If a file doesn't exist, say so — don't guess its contents."
),
"context_loss": (
"When you need context from earlier in the conversation, "
"use session_search to find it. Don't ask the user to repeat themselves."
),
"tool_failure": (
"If a tool fails, check the error message and try a different approach. "
"Don't retry the exact same command — diagnose first."
),
}
class PromptLearner:
"""Analyze session transcripts and generate prompt improvements."""
def __init__(self):
PATCHES_DIR.mkdir(parents=True, exist_ok=True)
ROLLBACK_DIR.mkdir(parents=True, exist_ok=True)
def analyze_session(self, session_data: dict) -> List[FailurePattern]:
"""Analyze a session for failure patterns.
Args:
session_data: Session dict with 'messages' list.
Returns:
List of detected failure patterns.
"""
messages = session_data.get("messages", [])
patterns_found: Dict[str, FailurePattern] = {}
for msg in messages:
content = str(msg.get("content", ""))
role = msg.get("role", "")
# Only analyze assistant messages and tool results
if role not in ("assistant", "tool"):
continue
for failure_type, config in FAILURE_SIGNALS.items():
for pattern in config["patterns"]:
if re.search(pattern, content):
if failure_type not in patterns_found:
patterns_found[failure_type] = FailurePattern(
pattern_type=failure_type,
description=config["description"],
frequency=0,
suggested_fix=PROMPT_FIXES.get(failure_type, ""),
)
patterns_found[failure_type].frequency += 1
if len(patterns_found[failure_type].example_messages) < 3:
patterns_found[failure_type].example_messages.append(
content[:200]
)
break # One match per message per type is enough
return list(patterns_found.values())
def generate_patches(self, patterns: List[FailurePattern],
min_confidence: float = 0.7) -> List[PromptPatch]:
"""Generate prompt patches from failure patterns.
Args:
patterns: Detected failure patterns.
min_confidence: Minimum confidence to generate a patch.
Returns:
List of prompt patches.
"""
patches = []
for pattern in patterns:
# Confidence based on frequency
if pattern.frequency >= 3:
confidence = 0.9
elif pattern.frequency >= 2:
confidence = 0.75
else:
confidence = 0.5
if confidence < min_confidence:
continue
if not pattern.suggested_fix:
continue
patch = PromptPatch(
id=f"{pattern.pattern_type}-{int(time.time())}",
failure_type=pattern.pattern_type,
original_rule="(missing — no existing rule for this pattern)",
new_rule=pattern.suggested_fix,
confidence=confidence,
)
patches.append(patch)
return patches
def apply_patches(self, patches: List[PromptPatch],
prompt_path: Optional[str] = None) -> int:
"""Apply patches to the system prompt.
Args:
patches: Patches to apply.
prompt_path: Path to prompt file (default: ~/.hermes/system_prompt.md)
Returns:
Number of patches applied.
"""
if prompt_path is None:
prompt_path = str(HERMES_HOME / "system_prompt.md")
prompt_file = Path(prompt_path)
# Backup current prompt
if prompt_file.exists():
backup = ROLLBACK_DIR / f"{prompt_file.name}.{int(time.time())}.bak"
backup.write_text(prompt_file.read_text())
# Read current prompt
current = prompt_file.read_text() if prompt_file.exists() else ""
# Apply patches
applied = 0
additions = []
for patch in patches:
if patch.new_rule not in current:
additions.append(f"\n## Auto-learned: {patch.failure_type}\n{patch.new_rule}")
patch.applied_at = time.time()
applied += 1
if additions:
new_content = current + "\n".join(additions)
prompt_file.write_text(new_content)
# Log patches
patches_file = PATCHES_DIR / f"patches-{int(time.time())}.json"
with open(patches_file, "w") as f:
json.dump([p.__dict__ for p in patches], f, indent=2, default=str)
logger.info("Applied %d prompt patches", applied)
return applied
def rollback_last(self, prompt_path: Optional[str] = None) -> bool:
"""Rollback to the most recent backup.
Args:
prompt_path: Path to prompt file.
Returns:
True if rollback succeeded.
"""
if prompt_path is None:
prompt_path = str(HERMES_HOME / "system_prompt.md")
backups = sorted(ROLLBACK_DIR.glob("*.bak"), reverse=True)
if not backups:
logger.warning("No backups to rollback to")
return False
latest = backups[0]
Path(prompt_path).write_text(latest.read_text())
logger.info("Rolled back to %s", latest.name)
return True
def learn_from_session(self, session_data: dict) -> Dict[str, Any]:
"""Full learning cycle: analyze → patch → apply.
Args:
session_data: Session dict.
Returns:
Summary of what was learned and applied.
"""
patterns = self.analyze_session(session_data)
patches = self.generate_patches(patterns)
applied = self.apply_patches(patches)
return {
"patterns_detected": len(patterns),
"patches_generated": len(patches),
"patches_applied": applied,
"patterns": [
{"type": p.pattern_type, "frequency": p.frequency, "description": p.description}
for p in patterns
],
}

231
agent/session_compactor.py Normal file
View File

@@ -0,0 +1,231 @@
"""Session compaction with fact extraction.
Before compressing conversation context, extracts durable facts
(user preferences, corrections, project details) and saves them
to the fact store so they survive compression.
Usage:
from agent.session_compactor import extract_and_save_facts
facts = extract_and_save_facts(messages)
"""
from __future__ import annotations
import json
import logging
import re
import time
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Tuple
logger = logging.getLogger(__name__)
@dataclass
class ExtractedFact:
"""A fact extracted from conversation."""
category: str # "user_pref", "correction", "project", "tool_quirk", "general"
entity: str # what the fact is about
content: str # the fact itself
confidence: float # 0.0-1.0
source_turn: int # which message turn it came from
timestamp: float = 0.0
# Patterns that indicate user preferences
_PREFERENCE_PATTERNS = [
(r"(?:I|we) (?:prefer|like|want|need) (.+?)(?:\.|$)", "preference"),
(r"(?:always|never) (?:use|do|run|deploy) (.+?)(?:\.|$)", "preference"),
(r"(?:my|our) (?:default|preferred|usual) (.+?) (?:is|are) (.+?)(?:\.|$)", "preference"),
(r"(?:make sure|ensure|remember) (?:to|that) (.+?)(?:\.|$)", "instruction"),
(r"(?:don'?t|do not) (?:ever|ever again) (.+?)(?:\.|$)", "constraint"),
]
# Patterns that indicate corrections
_CORRECTION_PATTERNS = [
(r"(?:actually|no[, ]|wait[, ]|correction[: ]|sorry[, ]) (.+)", "correction"),
(r"(?:I meant|what I meant was|the correct) (.+?)(?:\.|$)", "correction"),
(r"(?:it'?s|its) (?:not|shouldn'?t be|wrong) (.+?)(?:\.|$)", "correction"),
]
# Patterns that indicate project/tool facts
_PROJECT_PATTERNS = [
(r"(?:the |our )?(?:project|repo|codebase|code) (?:is|uses|needs|requires) (.+?)(?:\.|$)", "project"),
(r"(?:deploy|push|commit) (?:to|on) (.+?)(?:\.|$)", "project"),
(r"(?:this|that|the) (?:server|host|machine|VPS) (?:is|runs|has) (.+?)(?:\.|$)", "infrastructure"),
(r"(?:model|provider|engine) (?:is|should be|needs to be) (.+?)(?:\.|$)", "config"),
]
def extract_facts_from_messages(messages: List[Dict[str, Any]]) -> List[ExtractedFact]:
"""Extract durable facts from conversation messages.
Scans user messages for preferences, corrections, project facts,
and infrastructure details that should survive compression.
"""
facts = []
seen_contents = set()
for turn_idx, msg in enumerate(messages):
role = msg.get("role", "")
content = msg.get("content", "")
# Only scan user messages and assistant responses with corrections
if role not in ("user", "assistant"):
continue
if not content or not isinstance(content, str):
continue
if len(content) < 10:
continue
# Skip tool results and system messages
if role == "assistant" and msg.get("tool_calls"):
continue
extracted = _extract_from_text(content, turn_idx, role)
# Deduplicate by content
for fact in extracted:
key = f"{fact.category}:{fact.content[:100]}"
if key not in seen_contents:
seen_contents.add(key)
facts.append(fact)
return facts
def _extract_from_text(text: str, turn_idx: int, role: str) -> List[ExtractedFact]:
"""Extract facts from a single text block."""
facts = []
timestamp = time.time()
# Clean text for pattern matching
clean = text.strip()
# User preference patterns (from user messages)
if role == "user":
for pattern, subcategory in _PREFERENCE_PATTERNS:
for match in re.finditer(pattern, clean, re.IGNORECASE):
content = match.group(1).strip() if match.lastindex else match.group(0).strip()
if len(content) > 5:
facts.append(ExtractedFact(
category=f"user_pref.{subcategory}",
entity="user",
content=content[:200],
confidence=0.7,
source_turn=turn_idx,
timestamp=timestamp,
))
# Correction patterns (from user messages)
if role == "user":
for pattern, subcategory in _CORRECTION_PATTERNS:
for match in re.finditer(pattern, clean, re.IGNORECASE):
content = match.group(1).strip() if match.lastindex else match.group(0).strip()
if len(content) > 5:
facts.append(ExtractedFact(
category=f"correction.{subcategory}",
entity="user",
content=content[:200],
confidence=0.8,
source_turn=turn_idx,
timestamp=timestamp,
))
# Project/infrastructure patterns (from both user and assistant)
for pattern, subcategory in _PROJECT_PATTERNS:
for match in re.finditer(pattern, clean, re.IGNORECASE):
content = match.group(1).strip() if match.lastindex else match.group(0).strip()
if len(content) > 5:
facts.append(ExtractedFact(
category=f"project.{subcategory}",
entity=subcategory,
content=content[:200],
confidence=0.6,
source_turn=turn_idx,
timestamp=timestamp,
))
return facts
def save_facts_to_store(facts: List[ExtractedFact], fact_store_fn=None) -> int:
"""Save extracted facts to the fact store.
Args:
facts: List of extracted facts.
fact_store_fn: Optional callable(category, entity, content, trust).
If None, uses the holographic fact store if available.
Returns:
Number of facts saved.
"""
saved = 0
if fact_store_fn:
for fact in facts:
try:
fact_store_fn(
category=fact.category,
entity=fact.entity,
content=fact.content,
trust=fact.confidence,
)
saved += 1
except Exception as e:
logger.debug("Failed to save fact: %s", e)
else:
# Try holographic fact store
try:
from fact_store import fact_store as _fs
for fact in facts:
try:
_fs(
action="add",
content=fact.content,
category=fact.category,
tags=fact.entity,
trust_delta=fact.confidence - 0.5,
)
saved += 1
except Exception as e:
logger.debug("Failed to save fact via fact_store: %s", e)
except ImportError:
logger.debug("fact_store not available — facts not persisted")
return saved
def extract_and_save_facts(
messages: List[Dict[str, Any]],
fact_store_fn=None,
) -> Tuple[List[ExtractedFact], int]:
"""Extract facts from messages and save them.
Returns (extracted_facts, saved_count).
"""
facts = extract_facts_from_messages(messages)
if facts:
logger.info("Extracted %d facts from conversation", len(facts))
saved = save_facts_to_store(facts, fact_store_fn)
logger.info("Saved %d/%d facts to store", saved, len(facts))
else:
saved = 0
return facts, saved
def format_facts_summary(facts: List[ExtractedFact]) -> str:
"""Format extracted facts as a readable summary."""
if not facts:
return "No facts extracted."
by_category = {}
for f in facts:
by_category.setdefault(f.category, []).append(f)
lines = [f"Extracted {len(facts)} facts:", ""]
for cat, cat_facts in sorted(by_category.items()):
lines.append(f" {cat}:")
for f in cat_facts:
lines.append(f" - {f.content[:80]}")
return "\n".join(lines)

View File

@@ -12,13 +12,7 @@ from datetime import datetime
from pathlib import Path
from typing import Any, Dict, Optional
from agent.skill_security import (
validate_skill_name,
resolve_skill_path,
SkillSecurityError,
PathTraversalError,
InvalidSkillNameError,
)
from hermes_constants import display_hermes_home
logger = logging.getLogger(__name__)
@@ -56,37 +50,17 @@ def _load_skill_payload(skill_identifier: str, task_id: str | None = None) -> tu
if not raw_identifier:
return None
# Security: Validate skill identifier to prevent path traversal (V-011)
try:
validate_skill_name(raw_identifier, allow_path_separator=True)
except SkillSecurityError as e:
logger.warning("Security: Blocked skill loading attempt with invalid identifier '%s': %s", raw_identifier, e)
return None
try:
from tools.skills_tool import SKILLS_DIR, skill_view
# Security: Block absolute paths and home directory expansion attempts
identifier_path = Path(raw_identifier)
identifier_path = Path(raw_identifier).expanduser()
if identifier_path.is_absolute():
logger.warning("Security: Blocked absolute path in skill identifier: %s", raw_identifier)
return None
# Normalize the identifier: remove leading slashes and validate
normalized = raw_identifier.lstrip("/")
# Security: Double-check no traversal patterns remain after normalization
if ".." in normalized or "~" in normalized:
logger.warning("Security: Blocked path traversal in skill identifier: %s", raw_identifier)
return None
# Security: Verify the resolved path stays within SKILLS_DIR
try:
target_path = (SKILLS_DIR / normalized).resolve()
target_path.relative_to(SKILLS_DIR.resolve())
except (ValueError, OSError):
logger.warning("Security: Skill path escapes skills directory: %s", raw_identifier)
return None
try:
normalized = str(identifier_path.resolve().relative_to(SKILLS_DIR.resolve()))
except Exception:
normalized = raw_identifier
else:
normalized = raw_identifier.lstrip("/")
loaded_skill = json.loads(skill_view(normalized, task_id=task_id))
except Exception:
@@ -136,7 +110,7 @@ def _inject_skill_config(loaded_skill: dict[str, Any], parts: list[str]) -> None
if not resolved:
return
lines = ["", "[Skill config (from ~/.hermes/config.yaml):"]
lines = ["", f"[Skill config (from {display_hermes_home()}/config.yaml):"]
for key, value in resolved.items():
display_val = str(value) if value else "(not set)"
lines.append(f" {key} = {display_val}")
@@ -196,7 +170,7 @@ def _build_skill_message(
subdir_path = skill_dir / subdir
if subdir_path.exists():
for f in sorted(subdir_path.rglob("*")):
if f.is_file():
if f.is_file() and not f.is_symlink():
rel = str(f.relative_to(skill_dir))
supporting.append(rel)

View File

@@ -1,213 +0,0 @@
"""Security utilities for skill loading and validation.
Provides path traversal protection and input validation for skill names
to prevent security vulnerabilities like V-011 (Skills Guard Bypass).
"""
import re
from pathlib import Path
from typing import Optional, Tuple
# Strict skill name validation: alphanumeric, hyphens, underscores only
# This prevents path traversal attacks via skill names like "../../../etc/passwd"
VALID_SKILL_NAME_PATTERN = re.compile(r'^[a-zA-Z0-9._-]+$')
# Maximum skill name length to prevent other attack vectors
MAX_SKILL_NAME_LENGTH = 256
# Suspicious patterns that indicate path traversal attempts
PATH_TRAVERSAL_PATTERNS = [
"..", # Parent directory reference
"~", # Home directory expansion
"/", # Absolute path (Unix)
"\\", # Windows path separator
"//", # Protocol-relative or UNC path
"file:", # File protocol
"ftp:", # FTP protocol
"http:", # HTTP protocol
"https:", # HTTPS protocol
"data:", # Data URI
"javascript:", # JavaScript protocol
"vbscript:", # VBScript protocol
]
# Characters that should never appear in skill names
INVALID_CHARACTERS = set([
'\x00', '\x01', '\x02', '\x03', '\x04', '\x05', '\x06', '\x07',
'\x08', '\x09', '\x0a', '\x0b', '\x0c', '\x0d', '\x0e', '\x0f',
'\x10', '\x11', '\x12', '\x13', '\x14', '\x15', '\x16', '\x17',
'\x18', '\x19', '\x1a', '\x1b', '\x1c', '\x1d', '\x1e', '\x1f',
'<', '>', '|', '&', ';', '$', '`', '"', "'",
])
class SkillSecurityError(Exception):
"""Raised when a skill name fails security validation."""
pass
class PathTraversalError(SkillSecurityError):
"""Raised when path traversal is detected in a skill name."""
pass
class InvalidSkillNameError(SkillSecurityError):
"""Raised when a skill name contains invalid characters."""
pass
def validate_skill_name(name: str, allow_path_separator: bool = False) -> None:
"""Validate a skill name for security issues.
Args:
name: The skill name or identifier to validate
allow_path_separator: If True, allows '/' for category/skill paths (e.g., "mlops/axolotl")
Raises:
PathTraversalError: If path traversal patterns are detected
InvalidSkillNameError: If the name contains invalid characters
SkillSecurityError: For other security violations
"""
if not name or not isinstance(name, str):
raise InvalidSkillNameError("Skill name must be a non-empty string")
if len(name) > MAX_SKILL_NAME_LENGTH:
raise InvalidSkillNameError(
f"Skill name exceeds maximum length of {MAX_SKILL_NAME_LENGTH} characters"
)
# Check for null bytes and other control characters
for char in name:
if char in INVALID_CHARACTERS:
raise InvalidSkillNameError(
f"Skill name contains invalid character: {repr(char)}"
)
# Validate against allowed character pattern first
pattern = r'^[a-zA-Z0-9._-]+$' if not allow_path_separator else r'^[a-zA-Z0-9._/-]+$'
if not re.match(pattern, name):
invalid_chars = set(c for c in name if not re.match(r'[a-zA-Z0-9._/-]', c))
raise InvalidSkillNameError(
f"Skill name contains invalid characters: {sorted(invalid_chars)}. "
"Only alphanumeric characters, hyphens, underscores, dots, "
f"{'and forward slashes ' if allow_path_separator else ''}are allowed."
)
# Check for path traversal patterns (excluding '/' when path separators are allowed)
name_lower = name.lower()
patterns_to_check = PATH_TRAVERSAL_PATTERNS.copy()
if allow_path_separator:
# Remove '/' from patterns when path separators are allowed
patterns_to_check = [p for p in patterns_to_check if p != '/']
for pattern in patterns_to_check:
if pattern in name_lower:
raise PathTraversalError(
f"Path traversal detected in skill name: '{pattern}' is not allowed"
)
def resolve_skill_path(
skill_name: str,
skills_base_dir: Path,
allow_path_separator: bool = True
) -> Tuple[Path, Optional[str]]:
"""Safely resolve a skill name to a path within the skills directory.
Args:
skill_name: The skill name or path (e.g., "axolotl" or "mlops/axolotl")
skills_base_dir: The base skills directory
allow_path_separator: Whether to allow '/' in skill names for categories
Returns:
Tuple of (resolved_path, error_message)
- If successful: (resolved_path, None)
- If failed: (skills_base_dir, error_message)
Raises:
PathTraversalError: If the resolved path would escape the skills directory
"""
try:
validate_skill_name(skill_name, allow_path_separator=allow_path_separator)
except SkillSecurityError as e:
return skills_base_dir, str(e)
# Build the target path
try:
target_path = (skills_base_dir / skill_name).resolve()
except (OSError, ValueError) as e:
return skills_base_dir, f"Invalid skill path: {e}"
# Ensure the resolved path is within the skills directory
try:
target_path.relative_to(skills_base_dir.resolve())
except ValueError:
raise PathTraversalError(
f"Skill path '{skill_name}' resolves outside the skills directory boundary"
)
return target_path, None
def sanitize_skill_identifier(identifier: str) -> str:
"""Sanitize a skill identifier by removing dangerous characters.
This is a defensive fallback for cases where strict validation
cannot be applied. It removes or replaces dangerous characters.
Args:
identifier: The raw skill identifier
Returns:
A sanitized version of the identifier
"""
if not identifier:
return ""
# Replace path traversal sequences
sanitized = identifier.replace("..", "")
sanitized = sanitized.replace("//", "/")
# Remove home directory expansion
if sanitized.startswith("~"):
sanitized = sanitized[1:]
# Remove protocol handlers
for protocol in ["file:", "ftp:", "http:", "https:", "data:", "javascript:", "vbscript:"]:
sanitized = sanitized.replace(protocol, "")
sanitized = sanitized.replace(protocol.upper(), "")
# Remove null bytes and control characters
for char in INVALID_CHARACTERS:
sanitized = sanitized.replace(char, "")
# Normalize path separators to forward slash
sanitized = sanitized.replace("\\", "/")
# Remove leading/trailing slashes and whitespace
sanitized = sanitized.strip("/ ").strip()
return sanitized
def is_safe_skill_path(path: Path, allowed_base_dirs: list[Path]) -> bool:
"""Check if a path is safely within allowed directories.
Args:
path: The path to check
allowed_base_dirs: List of allowed base directories
Returns:
True if the path is within allowed boundaries, False otherwise
"""
try:
resolved = path.resolve()
for base_dir in allowed_base_dirs:
try:
resolved.relative_to(base_dir.resolve())
return True
except ValueError:
continue
return False
except (OSError, ValueError):
return False

View File

@@ -12,7 +12,7 @@ import sys
from pathlib import Path
from typing import Any, Dict, List, Optional, Set, Tuple
from hermes_constants import get_hermes_home
from hermes_constants import get_config_path, get_skills_dir
logger = logging.getLogger(__name__)
@@ -130,7 +130,7 @@ def get_disabled_skill_names(platform: str | None = None) -> Set[str]:
Reads the config file directly (no CLI config imports) to stay
lightweight.
"""
config_path = get_hermes_home() / "config.yaml"
config_path = get_config_path()
if not config_path.exists():
return set()
try:
@@ -145,10 +145,11 @@ def get_disabled_skill_names(platform: str | None = None) -> Set[str]:
if not isinstance(skills_cfg, dict):
return set()
from gateway.session_context import get_session_env
resolved_platform = (
platform
or os.getenv("HERMES_PLATFORM")
or os.getenv("HERMES_SESSION_PLATFORM")
or get_session_env("HERMES_SESSION_PLATFORM")
)
if resolved_platform:
platform_disabled = (skills_cfg.get("platform_disabled") or {}).get(
@@ -177,7 +178,7 @@ def get_external_skills_dirs() -> List[Path]:
path. Only directories that actually exist are returned. Duplicates and
paths that resolve to the local ``~/.hermes/skills/`` are silently skipped.
"""
config_path = get_hermes_home() / "config.yaml"
config_path = get_config_path()
if not config_path.exists():
return []
try:
@@ -199,7 +200,7 @@ def get_external_skills_dirs() -> List[Path]:
if not isinstance(raw_dirs, list):
return []
local_skills = (get_hermes_home() / "skills").resolve()
local_skills = get_skills_dir().resolve()
seen: Set[Path] = set()
result: List[Path] = []
@@ -229,7 +230,7 @@ def get_all_skills_dirs() -> List[Path]:
The local dir is always first (and always included even if it doesn't exist
yet — callers handle that). External dirs follow in config order.
"""
dirs = [get_hermes_home() / "skills"]
dirs = [get_skills_dir()]
dirs.extend(get_external_skills_dirs())
return dirs
@@ -383,7 +384,7 @@ def resolve_skill_config_values(
current values (or the declared default if the key isn't set).
Path values are expanded via ``os.path.expanduser``.
"""
config_path = get_hermes_home() / "config.yaml"
config_path = get_config_path()
config: Dict[str, Any] = {}
if config_path.exists():
try:
@@ -440,3 +441,25 @@ def iter_skill_index_files(skills_dir: Path, filename: str):
matches.append(Path(root) / filename)
for path in sorted(matches, key=lambda p: str(p.relative_to(skills_dir))):
yield path
# ── Namespace helpers for plugin-provided skills ───────────────────────────
_NAMESPACE_RE = re.compile(r"^[a-zA-Z0-9_-]+$")
def parse_qualified_name(name: str) -> Tuple[Optional[str], str]:
"""Split ``'namespace:skill-name'`` into ``(namespace, bare_name)``.
Returns ``(None, name)`` when there is no ``':'``.
"""
if ":" not in name:
return None, name
return tuple(name.split(":", 1)) # type: ignore[return-value]
def is_valid_namespace(candidate: Optional[str]) -> bool:
"""Check whether *candidate* is a valid namespace (``[a-zA-Z0-9_-]+``)."""
if not candidate:
return False
return bool(_NAMESPACE_RE.match(candidate))

View File

@@ -181,6 +181,7 @@ def resolve_turn_route(user_message: str, routing_config: Optional[Dict[str, Any
"api_mode": runtime.get("api_mode"),
"command": runtime.get("command"),
"args": list(runtime.get("args") or []),
"credential_pool": runtime.get("credential_pool"),
},
"label": f"smart route → {route.get('model')} ({runtime.get('provider')})",
"signature": (

View File

@@ -15,7 +15,6 @@ Inspired by Block/goose's SubdirectoryHintTracker.
import logging
import os
import re
import shlex
from pathlib import Path
from typing import Dict, Any, Optional, Set
@@ -160,7 +159,10 @@ class SubdirectoryHintTracker:
def _is_valid_subdir(self, path: Path) -> bool:
"""Check if path is a valid directory to scan for hints."""
if not path.is_dir():
try:
if not path.is_dir():
return False
except OSError:
return False
if path in self._loaded_dirs:
return False
@@ -173,7 +175,10 @@ class SubdirectoryHintTracker:
found_hints = []
for filename in _HINT_FILENAMES:
hint_path = directory / filename
if not hint_path.is_file():
try:
if not hint_path.is_file():
continue
except OSError:
continue
try:
content = hint_path.read_text(encoding="utf-8").strip()

View File

@@ -1,74 +0,0 @@
"""Sovereign Intersymbolic Memory Layer.
Bridges Neural (LLM) and Symbolic (Graph) reasoning by extracting
structured triples from unstructured text and performing graph lookups.
"""
import logging
import json
from typing import List, Dict, Any
from agent.gemini_adapter import GeminiAdapter
from tools.graph_store import GraphStore
logger = logging.getLogger(__name__)
class SymbolicMemory:
def __init__(self):
self.adapter = GeminiAdapter()
self.store = GraphStore()
def ingest_text(self, text: str):
"""Extracts triples from text and adds them to the graph."""
prompt = f"""
Extract all meaningful entities and their relationships from the following text.
Format the output as a JSON list of triples: [{{"s": "subject", "p": "predicate", "o": "object"}}]
Text:
{text}
Guidelines:
- Use clear, concise labels for entities and predicates.
- Focus on stable facts and structural relationships.
- Predicates should be verbs or descriptive relations (e.g., 'is_a', 'works_at', 'collaborates_with').
"""
try:
result = self.adapter.generate(
model="gemini-3.1-pro-preview",
prompt=prompt,
system_instruction="You are Timmy's Symbolic Extraction Engine. Extract high-fidelity knowledge triples.",
response_mime_type="application/json"
)
triples = json.loads(result["text"])
if isinstance(triples, list):
count = self.store.add_triples(triples)
logger.info(f"Ingested {count} new triples into symbolic memory.")
return count
except Exception as e:
logger.error(f"Symbolic ingestion failed: {e}")
return 0
def get_context_for(self, topic: str) -> str:
"""Performs a 2-hop graph search to find related context for a topic."""
# 1. Find direct relations
direct = self.store.query(subject=topic) + self.store.query(object=topic)
# 2. Find 2nd hop
related_entities = set()
for t in direct:
related_entities.add(t['s'])
related_entities.add(t['o'])
extended = []
for entity in related_entities:
if entity == topic: continue
extended.extend(self.store.query(subject=entity))
all_triples = direct + extended
if not all_triples:
return ""
context = "Symbolic Knowledge Graph Context:\n"
for t in all_triples:
context += f"- {t['s']} --({t['p']})--> {t['o']}\n"
return context

View File

@@ -1,421 +0,0 @@
"""Temporal Knowledge Graph for Hermes Agent.
Provides a time-aware triple-store (Subject, Predicate, Object) with temporal
metadata (valid_from, valid_until, timestamp) enabling "time travel" queries
over Timmy's evolving worldview.
Time format: ISO 8601 (YYYY-MM-DDTHH:MM:SS)
"""
import json
import sqlite3
import logging
import uuid
from datetime import datetime, timezone
from typing import List, Dict, Any, Optional, Tuple
from dataclasses import dataclass, asdict
from enum import Enum
from pathlib import Path
logger = logging.getLogger(__name__)
class TemporalOperator(Enum):
"""Temporal query operators for time-based filtering."""
BEFORE = "before"
AFTER = "after"
DURING = "during"
OVERLAPS = "overlaps"
AT = "at"
@dataclass
class TemporalTriple:
"""A triple with temporal metadata."""
id: str
subject: str
predicate: str
object: str
valid_from: str # ISO 8601 datetime
valid_until: Optional[str] # ISO 8601 datetime, None means still valid
timestamp: str # When this fact was recorded
version: int = 1
superseded_by: Optional[str] = None # ID of the triple that superseded this
def to_dict(self) -> Dict[str, Any]:
return asdict(self)
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "TemporalTriple":
return cls(**data)
class TemporalTripleStore:
"""SQLite-backed temporal triple store with versioning support."""
def __init__(self, db_path: Optional[str] = None):
"""Initialize the temporal triple store.
Args:
db_path: Path to SQLite database. If None, uses default local path.
"""
if db_path is None:
# Default to local-first storage in user's home
home = Path.home()
db_dir = home / ".hermes" / "temporal_kg"
db_dir.mkdir(parents=True, exist_ok=True)
db_path = db_dir / "temporal_kg.db"
self.db_path = str(db_path)
self._init_db()
def _init_db(self):
"""Initialize the SQLite database with required tables."""
with sqlite3.connect(self.db_path) as conn:
conn.execute("""
CREATE TABLE IF NOT EXISTS temporal_triples (
id TEXT PRIMARY KEY,
subject TEXT NOT NULL,
predicate TEXT NOT NULL,
object TEXT NOT NULL,
valid_from TEXT NOT NULL,
valid_until TEXT,
timestamp TEXT NOT NULL,
version INTEGER DEFAULT 1,
superseded_by TEXT,
FOREIGN KEY (superseded_by) REFERENCES temporal_triples(id)
)
""")
# Create indexes for efficient querying
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_subject ON temporal_triples(subject)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_predicate ON temporal_triples(predicate)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_valid_from ON temporal_triples(valid_from)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_valid_until ON temporal_triples(valid_until)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_timestamp ON temporal_triples(timestamp)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_subject_predicate
ON temporal_triples(subject, predicate)
""")
conn.commit()
def _now(self) -> str:
"""Get current time in ISO 8601 format."""
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")
def _generate_id(self) -> str:
"""Generate a unique ID for a triple."""
return f"{self._now()}_{uuid.uuid4().hex[:8]}"
def store_fact(
self,
subject: str,
predicate: str,
object: str,
valid_from: Optional[str] = None,
valid_until: Optional[str] = None
) -> TemporalTriple:
"""Store a fact with temporal bounds.
Args:
subject: The subject of the triple
predicate: The predicate/relationship
object: The object/value
valid_from: When this fact becomes valid (ISO 8601). Defaults to now.
valid_until: When this fact expires (ISO 8601). None means forever valid.
Returns:
The stored TemporalTriple
"""
if valid_from is None:
valid_from = self._now()
# Check if there's an existing fact for this subject-predicate
existing = self._get_current_fact(subject, predicate)
triple = TemporalTriple(
id=self._generate_id(),
subject=subject,
predicate=predicate,
object=object,
valid_from=valid_from,
valid_until=valid_until,
timestamp=self._now()
)
with sqlite3.connect(self.db_path) as conn:
# If there's an existing fact, mark it as superseded
if existing:
existing.valid_until = valid_from
existing.superseded_by = triple.id
self._update_triple(conn, existing)
triple.version = existing.version + 1
# Insert the new fact
self._insert_triple(conn, triple)
conn.commit()
logger.info(f"Stored temporal fact: {subject} {predicate} {object} (valid from {valid_from})")
return triple
def _get_current_fact(self, subject: str, predicate: str) -> Optional[TemporalTriple]:
"""Get the current (most recent, still valid) fact for a subject-predicate pair."""
with sqlite3.connect(self.db_path) as conn:
cursor = conn.execute(
"""
SELECT * FROM temporal_triples
WHERE subject = ? AND predicate = ? AND valid_until IS NULL
ORDER BY timestamp DESC LIMIT 1
""",
(subject, predicate)
)
row = cursor.fetchone()
if row:
return self._row_to_triple(row)
return None
def _insert_triple(self, conn: sqlite3.Connection, triple: TemporalTriple):
"""Insert a triple into the database."""
conn.execute(
"""
INSERT INTO temporal_triples
(id, subject, predicate, object, valid_from, valid_until, timestamp, version, superseded_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
triple.id, triple.subject, triple.predicate, triple.object,
triple.valid_from, triple.valid_until, triple.timestamp,
triple.version, triple.superseded_by
)
)
def _update_triple(self, conn: sqlite3.Connection, triple: TemporalTriple):
"""Update an existing triple."""
conn.execute(
"""
UPDATE temporal_triples
SET valid_until = ?, superseded_by = ?
WHERE id = ?
""",
(triple.valid_until, triple.superseded_by, triple.id)
)
def _row_to_triple(self, row: sqlite3.Row) -> TemporalTriple:
"""Convert a database row to a TemporalTriple."""
return TemporalTriple(
id=row[0],
subject=row[1],
predicate=row[2],
object=row[3],
valid_from=row[4],
valid_until=row[5],
timestamp=row[6],
version=row[7],
superseded_by=row[8]
)
def query_at_time(
self,
timestamp: str,
subject: Optional[str] = None,
predicate: Optional[str] = None
) -> List[TemporalTriple]:
"""Query facts that were valid at a specific point in time.
Args:
timestamp: The point in time to query (ISO 8601)
subject: Optional subject filter
predicate: Optional predicate filter
Returns:
List of TemporalTriple objects valid at that time
"""
query = """
SELECT * FROM temporal_triples
WHERE valid_from <= ?
AND (valid_until IS NULL OR valid_until > ?)
"""
params = [timestamp, timestamp]
if subject:
query += " AND subject = ?"
params.append(subject)
if predicate:
query += " AND predicate = ?"
params.append(predicate)
query += " ORDER BY timestamp DESC"
with sqlite3.connect(self.db_path) as conn:
conn.row_factory = sqlite3.Row
cursor = conn.execute(query, params)
return [self._row_to_triple(row) for row in cursor.fetchall()]
def query_temporal(
self,
operator: TemporalOperator,
timestamp: str,
subject: Optional[str] = None,
predicate: Optional[str] = None
) -> List[TemporalTriple]:
"""Query using temporal operators.
Args:
operator: TemporalOperator (BEFORE, AFTER, DURING, OVERLAPS, AT)
timestamp: Reference timestamp (ISO 8601)
subject: Optional subject filter
predicate: Optional predicate filter
Returns:
List of matching TemporalTriple objects
"""
base_query = "SELECT * FROM temporal_triples WHERE 1=1"
params = []
if subject:
base_query += " AND subject = ?"
params.append(subject)
if predicate:
base_query += " AND predicate = ?"
params.append(predicate)
if operator == TemporalOperator.BEFORE:
base_query += " AND valid_from < ?"
params.append(timestamp)
elif operator == TemporalOperator.AFTER:
base_query += " AND valid_from > ?"
params.append(timestamp)
elif operator == TemporalOperator.DURING:
base_query += " AND valid_from <= ? AND (valid_until IS NULL OR valid_until > ?)"
params.extend([timestamp, timestamp])
elif operator == TemporalOperator.OVERLAPS:
# Facts that overlap with a time point (same as DURING)
base_query += " AND valid_from <= ? AND (valid_until IS NULL OR valid_until > ?)"
params.extend([timestamp, timestamp])
elif operator == TemporalOperator.AT:
# Exact match for valid_at query
return self.query_at_time(timestamp, subject, predicate)
base_query += " ORDER BY timestamp DESC"
with sqlite3.connect(self.db_path) as conn:
conn.row_factory = sqlite3.Row
cursor = conn.execute(base_query, params)
return [self._row_to_triple(row) for row in cursor.fetchall()]
def get_fact_history(
self,
subject: str,
predicate: str
) -> List[TemporalTriple]:
"""Get the complete version history of a fact.
Args:
subject: The subject to query
predicate: The predicate to query
Returns:
List of all versions of the fact, ordered by timestamp
"""
with sqlite3.connect(self.db_path) as conn:
conn.row_factory = sqlite3.Row
cursor = conn.execute(
"""
SELECT * FROM temporal_triples
WHERE subject = ? AND predicate = ?
ORDER BY timestamp ASC
""",
(subject, predicate)
)
return [self._row_to_triple(row) for row in cursor.fetchall()]
def get_all_facts_for_entity(
self,
subject: str,
at_time: Optional[str] = None
) -> List[TemporalTriple]:
"""Get all facts about an entity, optionally at a specific time.
Args:
subject: The entity to query
at_time: Optional timestamp to query at
Returns:
List of TemporalTriple objects
"""
if at_time:
return self.query_at_time(at_time, subject=subject)
with sqlite3.connect(self.db_path) as conn:
conn.row_factory = sqlite3.Row
cursor = conn.execute(
"""
SELECT * FROM temporal_triples
WHERE subject = ?
ORDER BY timestamp DESC
""",
(subject,)
)
return [self._row_to_triple(row) for row in cursor.fetchall()]
def get_entity_changes(
self,
subject: str,
start_time: str,
end_time: str
) -> List[TemporalTriple]:
"""Get all facts that changed for an entity during a time range.
Args:
subject: The entity to query
start_time: Start of time range (ISO 8601)
end_time: End of time range (ISO 8601)
Returns:
List of TemporalTriple objects that changed in the range
"""
with sqlite3.connect(self.db_path) as conn:
conn.row_factory = sqlite3.Row
cursor = conn.execute(
"""
SELECT * FROM temporal_triples
WHERE subject = ?
AND ((valid_from >= ? AND valid_from <= ?)
OR (valid_until >= ? AND valid_until <= ?))
ORDER BY timestamp ASC
""",
(subject, start_time, end_time, start_time, end_time)
)
return [self._row_to_triple(row) for row in cursor.fetchall()]
def close(self):
"""Close the database connection (no-op for SQLite with context managers)."""
pass
def export_to_json(self) -> str:
"""Export all triples to JSON format."""
with sqlite3.connect(self.db_path) as conn:
conn.row_factory = sqlite3.Row
cursor = conn.execute("SELECT * FROM temporal_triples ORDER BY timestamp DESC")
triples = [self._row_to_triple(row).to_dict() for row in cursor.fetchall()]
return json.dumps(triples, indent=2)
def import_from_json(self, json_data: str):
"""Import triples from JSON format."""
triples = json.loads(json_data)
with sqlite3.connect(self.db_path) as conn:
for triple_dict in triples:
triple = TemporalTriple.from_dict(triple_dict)
self._insert_triple(conn, triple)
conn.commit()

Some files were not shown because too many files have changed in this diff Show More