solidx-agenthub-runtime 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. solidx_agenthub_runtime-0.1.0/PKG-INFO +25 -0
  2. solidx_agenthub_runtime-0.1.0/README.md +267 -0
  3. solidx_agenthub_runtime-0.1.0/pyproject.toml +49 -0
  4. solidx_agenthub_runtime-0.1.0/setup.cfg +4 -0
  5. solidx_agenthub_runtime-0.1.0/src/agenthub/__init__.py +3 -0
  6. solidx_agenthub_runtime-0.1.0/src/agenthub/bridge/__init__.py +1 -0
  7. solidx_agenthub_runtime-0.1.0/src/agenthub/bridge/audit.py +72 -0
  8. solidx_agenthub_runtime-0.1.0/src/agenthub/bridge/auth.py +98 -0
  9. solidx_agenthub_runtime-0.1.0/src/agenthub/bridge/local.py +123 -0
  10. solidx_agenthub_runtime-0.1.0/src/agenthub/bridge/router.py +62 -0
  11. solidx_agenthub_runtime-0.1.0/src/agenthub/bridge/routes.py +120 -0
  12. solidx_agenthub_runtime-0.1.0/src/agenthub/bridge/server.py +215 -0
  13. solidx_agenthub_runtime-0.1.0/src/agenthub/bridge/service.py +83 -0
  14. solidx_agenthub_runtime-0.1.0/src/agenthub/bridge/sessions.py +80 -0
  15. solidx_agenthub_runtime-0.1.0/src/agenthub/bridge/signing.py +59 -0
  16. solidx_agenthub_runtime-0.1.0/src/agenthub/bridge/tool_session.py +69 -0
  17. solidx_agenthub_runtime-0.1.0/src/agenthub/builtin_catalog.json +130 -0
  18. solidx_agenthub_runtime-0.1.0/src/agenthub/cli.py +140 -0
  19. solidx_agenthub_runtime-0.1.0/src/agenthub/config/__init__.py +3 -0
  20. solidx_agenthub_runtime-0.1.0/src/agenthub/config/source_db.py +79 -0
  21. solidx_agenthub_runtime-0.1.0/src/agenthub/config/source_disk.py +59 -0
  22. solidx_agenthub_runtime-0.1.0/src/agenthub/config/spec.py +103 -0
  23. solidx_agenthub_runtime-0.1.0/src/agenthub/events/__init__.py +45 -0
  24. solidx_agenthub_runtime-0.1.0/src/agenthub/events/envelope.py +60 -0
  25. solidx_agenthub_runtime-0.1.0/src/agenthub/identity/__init__.py +129 -0
  26. solidx_agenthub_runtime-0.1.0/src/agenthub/inputs.py +68 -0
  27. solidx_agenthub_runtime-0.1.0/src/agenthub/llm/__init__.py +207 -0
  28. solidx_agenthub_runtime-0.1.0/src/agenthub/loop/__init__.py +217 -0
  29. solidx_agenthub_runtime-0.1.0/src/agenthub/manager/__init__.py +1 -0
  30. solidx_agenthub_runtime-0.1.0/src/agenthub/manager/app.py +489 -0
  31. solidx_agenthub_runtime-0.1.0/src/agenthub/manager/secrets.py +66 -0
  32. solidx_agenthub_runtime-0.1.0/src/agenthub/media.py +262 -0
  33. solidx_agenthub_runtime-0.1.0/src/agenthub/persistence/__init__.py +9 -0
  34. solidx_agenthub_runtime-0.1.0/src/agenthub/persistence/models/__init__.py +23 -0
  35. solidx_agenthub_runtime-0.1.0/src/agenthub/persistence/models/agent_event.py +28 -0
  36. solidx_agenthub_runtime-0.1.0/src/agenthub/persistence/models/agent_human_request.py +27 -0
  37. solidx_agenthub_runtime-0.1.0/src/agenthub/persistence/models/agent_job.py +34 -0
  38. solidx_agenthub_runtime-0.1.0/src/agenthub/persistence/models/agent_process.py +31 -0
  39. solidx_agenthub_runtime-0.1.0/src/agenthub/persistence/models/agent_registry.py +22 -0
  40. solidx_agenthub_runtime-0.1.0/src/agenthub/persistence/models/agent_role.py +15 -0
  41. solidx_agenthub_runtime-0.1.0/src/agenthub/persistence/models/agent_secret.py +16 -0
  42. solidx_agenthub_runtime-0.1.0/src/agenthub/persistence/models/agent_session.py +35 -0
  43. solidx_agenthub_runtime-0.1.0/src/agenthub/persistence/models/agent_session_checkpoint.py +18 -0
  44. solidx_agenthub_runtime-0.1.0/src/agenthub/persistence/models/agent_skill.py +15 -0
  45. solidx_agenthub_runtime-0.1.0/src/agenthub/persistence/models/agent_skill_registry.py +17 -0
  46. solidx_agenthub_runtime-0.1.0/src/agenthub/persistence/models/agent_tool.py +15 -0
  47. solidx_agenthub_runtime-0.1.0/src/agenthub/persistence/models/agent_tool_registry.py +22 -0
  48. solidx_agenthub_runtime-0.1.0/src/agenthub/persistence/models/base.py +62 -0
  49. solidx_agenthub_runtime-0.1.0/src/agenthub/persistence/models/mcp_audit_log.py +33 -0
  50. solidx_agenthub_runtime-0.1.0/src/agenthub/persistence/models/secret.py +27 -0
  51. solidx_agenthub_runtime-0.1.0/src/agenthub/persistence/repository.py +394 -0
  52. solidx_agenthub_runtime-0.1.0/src/agenthub/persistence/seed.py +61 -0
  53. solidx_agenthub_runtime-0.1.0/src/agenthub/process/__init__.py +1 -0
  54. solidx_agenthub_runtime-0.1.0/src/agenthub/process/agent_api.py +306 -0
  55. solidx_agenthub_runtime-0.1.0/src/agenthub/process/agent_tokens.py +78 -0
  56. solidx_agenthub_runtime-0.1.0/src/agenthub/process/app.py +578 -0
  57. solidx_agenthub_runtime-0.1.0/src/agenthub/process/bridge_api.py +149 -0
  58. solidx_agenthub_runtime-0.1.0/src/agenthub/process/local.py +276 -0
  59. solidx_agenthub_runtime-0.1.0/src/agenthub/runtime_prompt.py +64 -0
  60. solidx_agenthub_runtime-0.1.0/src/agenthub/sdk/__init__.py +10 -0
  61. solidx_agenthub_runtime-0.1.0/src/agenthub/sdk/context.py +59 -0
  62. solidx_agenthub_runtime-0.1.0/src/agenthub/sdk/project.py +14 -0
  63. solidx_agenthub_runtime-0.1.0/src/agenthub/sdk/tool.py +164 -0
  64. solidx_agenthub_runtime-0.1.0/src/agenthub/skills/__init__.py +25 -0
  65. solidx_agenthub_runtime-0.1.0/src/agenthub/skills/builtin/db_query_assistant.md +78 -0
  66. solidx_agenthub_runtime-0.1.0/src/agenthub/skills/builtin/filesystem.md +170 -0
  67. solidx_agenthub_runtime-0.1.0/src/agenthub/skills/builtin/quality_checks.md +24 -0
  68. solidx_agenthub_runtime-0.1.0/src/agenthub/skills/builtin/solidctl.md +68 -0
  69. solidx_agenthub_runtime-0.1.0/src/agenthub/skills/builtin/web_research.md +11 -0
  70. solidx_agenthub_runtime-0.1.0/src/agenthub/tools/__init__.py +1 -0
  71. solidx_agenthub_runtime-0.1.0/src/agenthub/tools/builtin/__init__.py +1 -0
  72. solidx_agenthub_runtime-0.1.0/src/agenthub/tools/builtin/bash.py +45 -0
  73. solidx_agenthub_runtime-0.1.0/src/agenthub/tools/builtin/filesystem.py +298 -0
  74. solidx_agenthub_runtime-0.1.0/src/agenthub/tools/builtin/list_widgets.py +12 -0
  75. solidx_agenthub_runtime-0.1.0/src/agenthub/tools/builtin/load_skill.py +12 -0
  76. solidx_agenthub_runtime-0.1.0/src/agenthub/tools/builtin/request_human_input.py +14 -0
  77. solidx_agenthub_runtime-0.1.0/src/agenthub/tools/builtin/run_read_only_sql.py +79 -0
  78. solidx_agenthub_runtime-0.1.0/src/agenthub/tools/builtin/web_search.py +129 -0
  79. solidx_agenthub_runtime-0.1.0/src/agenthub/tools/executor.py +102 -0
  80. solidx_agenthub_runtime-0.1.0/src/agenthub/tools/guard.py +49 -0
  81. solidx_agenthub_runtime-0.1.0/src/agenthub/tools/loader.py +73 -0
  82. solidx_agenthub_runtime-0.1.0/src/agenthub/tools/validate.py +52 -0
  83. solidx_agenthub_runtime-0.1.0/src/agenthub/widgets.py +34 -0
  84. solidx_agenthub_runtime-0.1.0/src/solidx_agenthub_runtime.egg-info/PKG-INFO +25 -0
  85. solidx_agenthub_runtime-0.1.0/src/solidx_agenthub_runtime.egg-info/SOURCES.txt +97 -0
  86. solidx_agenthub_runtime-0.1.0/src/solidx_agenthub_runtime.egg-info/dependency_links.txt +1 -0
  87. solidx_agenthub_runtime-0.1.0/src/solidx_agenthub_runtime.egg-info/entry_points.txt +2 -0
  88. solidx_agenthub_runtime-0.1.0/src/solidx_agenthub_runtime.egg-info/requires.txt +21 -0
  89. solidx_agenthub_runtime-0.1.0/src/solidx_agenthub_runtime.egg-info/top_level.txt +1 -0
  90. solidx_agenthub_runtime-0.1.0/tests/test_agent_chat.py +376 -0
  91. solidx_agenthub_runtime-0.1.0/tests/test_bridge.py +327 -0
  92. solidx_agenthub_runtime-0.1.0/tests/test_builtins.py +215 -0
  93. solidx_agenthub_runtime-0.1.0/tests/test_embed.py +113 -0
  94. solidx_agenthub_runtime-0.1.0/tests/test_event_parity.py +169 -0
  95. solidx_agenthub_runtime-0.1.0/tests/test_identity_roles.py +57 -0
  96. solidx_agenthub_runtime-0.1.0/tests/test_media.py +53 -0
  97. solidx_agenthub_runtime-0.1.0/tests/test_phase0.py +279 -0
  98. solidx_agenthub_runtime-0.1.0/tests/test_phase1.py +425 -0
  99. solidx_agenthub_runtime-0.1.0/tests/test_tool_lifecycle.py +156 -0
@@ -0,0 +1,25 @@
1
+ Metadata-Version: 2.4
2
+ Name: solidx-agenthub-runtime
3
+ Version: 0.1.0
4
+ Summary: SolidX AgentHub agent loop and runtime
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: mini-swe-agent<2.5,>=2.4.6
7
+ Requires-Dist: litellm
8
+ Requires-Dist: pydantic>=2
9
+ Requires-Dist: pyyaml
10
+ Requires-Dist: fastapi
11
+ Requires-Dist: uvicorn[standard]
12
+ Requires-Dist: sqlalchemy<2.1,>=2
13
+ Requires-Dist: psycopg2-binary
14
+ Requires-Dist: cryptography
15
+ Requires-Dist: requests
16
+ Requires-Dist: boto3
17
+ Requires-Dist: typer
18
+ Requires-Dist: mcp<2.3,>=2.2
19
+ Requires-Dist: httpx
20
+ Provides-Extra: dev
21
+ Requires-Dist: pytest; extra == "dev"
22
+ Requires-Dist: pytest-asyncio; extra == "dev"
23
+ Requires-Dist: httpx; extra == "dev"
24
+ Requires-Dist: ruff; extra == "dev"
25
+ Requires-Dist: build; extra == "dev"
@@ -0,0 +1,267 @@
1
+ # SolidX AgentHub runtime
2
+
3
+ Python agent loop and single-host runtime for SolidX-configured agents. See
4
+ [`docs/technical-design.md`](docs/technical-design.md) for the agreed behavior
5
+ and [`docs/module-contract.md`](docs/module-contract.md) for the SolidX-owned
6
+ schema and IAM integration points. Tables are SolidX Core's `ss_agent_*`
7
+ entities; the runtime only reads and writes rows and never changes the schema.
8
+
9
+ ## Development
10
+
11
+ The PyPI release procedure (stable from `main`, beta/RC from `dev`) is documented in
12
+ [`docs/RELEASE.md`](docs/RELEASE.md) and implemented in [`.github/workflows/release.yml`](.github/workflows/release.yml).
13
+ Configure this repository's PyPI Trusted Publisher and GitHub environments before the first release.
14
+
15
+ Requires Python 3.11+.
16
+ The mini-SWE-agent dependency is pinned to the 2.4.x line exercised by the
17
+ contract and runtime tests; upgrade only after rerunning those tests.
18
+
19
+ ```sh
20
+ python -m venv .venv
21
+ .venv/bin/python -m pip install -e '.[dev]'
22
+ .venv/bin/python -m pytest -q
23
+ .venv/bin/python -m ruff check src tests
24
+ PYTHONPATH=src .venv/bin/python scripts/load_smoke.py
25
+ ```
26
+
27
+ The disk harness requires no SolidX database. Copy
28
+ `examples/osv-review/secrets.env.example` to `secrets.env` (gitignored), set
29
+ your local model key, and update `llm.json` with your own compatible model
30
+ endpoint/model name. Run:
31
+
32
+ ```sh
33
+ agenthub-runtime run examples/osv-review "Check APP-2026-00123"
34
+ agenthub-runtime serve --config-source disk --path examples/osv-review --port 9100
35
+ ```
36
+
37
+ Disk `serve` is **unauthenticated and loopback-only**. `POST /api/sessions`
38
+ returns a session ID and `/ws/sessions/{id}` path. The WebSocket accepts
39
+ `message`, `human_response`, `cancel`, and `end_session` actions. Its event
40
+ stream can be used to adapt the separately maintained admin chat UI.
41
+
42
+ For database mode, SolidX Core must supply the `ss_agent_*` tables (and the
43
+ settings documented in `docs/module-contract.md`). The manager requires `DATABASE_URL`,
44
+ `SOLIDX_API_BASE_URL`, and `APP_ENCRYPTION_KEY` (to resolve linked secrets).
45
+ `AGENTHUB_ADMIN_ROLES` defaults to `Admin`; set it to the comma-separated
46
+ SolidX role names used by your administrators if they differ. Put settings in
47
+ `.env` (copy `.env.example`; gitignored) or the OS environment, which wins. The manager
48
+ removes `APP_ENCRYPTION_KEY` from its child environment. Start the manager with
49
+ `agenthub-runtime manager --port 9000`. It spawns DB-mode `serve` processes
50
+ on allocated ports. `AGENTHUB_BIND_HOST` and `AGENTHUB_PUBLIC_HOST` must be
51
+ configured for the intended network path; default bindings use loopback.
52
+
53
+ The TypeScript client lives in `sdk/typescript/`; it uses built-in `fetch` and
54
+ exposes `agents(id).run`, `jobs.get/wait/cancel`, and
55
+ `humanRequests.answer`. Build it with `npm run build` in that directory once
56
+ TypeScript is available.
57
+
58
+ ## Test an agent asynchronously
59
+
60
+ This exercises the manager job API. The manager validates a SolidX JWT,
61
+ enqueues the job, and spawns a runtime process if none is ready. Do not start
62
+ `serve` yourself. Seed the example agent with `scripts/setup_ai_news_agent.py`
63
+ (it only inserts rows into Core's tables, which must already exist; it is safe
64
+ to re-run). Agents are addressed by Core's numeric id, which the script prints
65
+ (or `SELECT id FROM ss_agent_registry WHERE name = 'ai-news'`). The caller must
66
+ already have a role linked in `ss_agent_role` for the agent: insert
67
+ `(agent_registry_id, role_metadata_id)` with the caller's SolidX role id before
68
+ running the example below.
69
+
70
+ Use three terminals. The SolidX API can be any SolidX consuming project.
71
+
72
+ 1. Start that project's API, and leave it running:
73
+
74
+ ```sh
75
+ cd /path/to/any-solidx-consuming-project/solid-api
76
+ npm run solidx:dev
77
+ ```
78
+
79
+ Wait until it prints that the server started. Use that port below. These
80
+ examples assume `http://localhost:3000`.
81
+
82
+ 2. Start the manager against the same project's database. Take the user,
83
+ password, host, port, and database name from that project's `solid-api/.env`
84
+ (`DEFAULT_DATABASE_*`), and `SOLIDX_API_BASE_URL` from the same setting (or `BASE_URL` for older projects).
85
+
86
+ ```sh
87
+ export DATABASE_URL='postgresql://USER:PASSWORD@localhost:5432/DATABASE'
88
+ export SOLIDX_API_BASE_URL='http://localhost:3000'
89
+
90
+ cd ~/Documents/Codes/solidx-agenthub-runtime
91
+ .venv/bin/agenthub-runtime manager --port 9000
92
+ ```
93
+
94
+ In a third terminal, confirm the manager:
95
+
96
+ ```sh
97
+ curl -s http://127.0.0.1:9000/health
98
+ ```
99
+
100
+ Expect `{"status":"ready"}`.
101
+
102
+ 3. Log in as a user allowed to run the agent and keep the access token:
103
+
104
+ ```sh
105
+ curl -s http://127.0.0.1:3000/api/iam/authenticate \
106
+ -H 'Content-Type: application/json' \
107
+ -d '{"username":"YOUR_USERNAME","password":"YOUR_PASSWORD"}'
108
+ ```
109
+
110
+ ```sh
111
+ export TOKEN='paste-the-access-token'
112
+ ```
113
+
114
+ 4. Create the job. This call also starts the agent process. Replace `AGENT_ID`
115
+ with the numeric id of the `ai-news` agent:
116
+
117
+ ```sh
118
+ curl -s http://127.0.0.1:9000/api/agents/AGENT_ID/jobs \
119
+ -H "Authorization: Bearer $TOKEN" \
120
+ -H 'Content-Type: application/json' \
121
+ -d '{"task":"Fetch AI news from the last 24 hours and summarize the titles and links."}'
122
+ ```
123
+
124
+ A 401 means the token was rejected. A 403 means this user has no role linked to
125
+ the agent. Save `jobId`, then confirm the spawned process:
126
+
127
+ ```sh
128
+ curl -s http://127.0.0.1:9100/health
129
+ ```
130
+
131
+ Expect `{"status":"ready","agent":AGENT_ID}`.
132
+
133
+ 5. Poll until the job finishes:
134
+
135
+ ```sh
136
+ curl -s http://127.0.0.1:9000/api/jobs/JOB_ID \
137
+ -H "Authorization: Bearer $TOKEN"
138
+ ```
139
+
140
+ `queued` or `running` means it is still working. `succeeded` puts the Hacker
141
+ News summary in `result`. `failed` puts the reason in `error`.
142
+
143
+ `result` is the `{"widgetName","data"}` envelope the model replies with; the
144
+ summary text is in `data`.
145
+
146
+ ### Use an API key instead of a JWT
147
+
148
+ `POST /api/agents/AGENT_ID/jobs` and the job-status call also accept a Solid API
149
+ key in the `solidx-api-key` header. The manager exchanges it at IAM for the user
150
+ and an access token, and the job runs as that user.
151
+
152
+ ```sh
153
+ export API_KEY='paste-the-solid-api-key'
154
+
155
+ curl -i http://127.0.0.1:9000/api/agents/AGENT_ID/jobs \
156
+ -H "solidx-api-key: $API_KEY" \
157
+ -H 'Content-Type: application/json' \
158
+ -d '{"task":"What can you do."}'
159
+
160
+ curl -i http://127.0.0.1:9000/api/jobs/JOB_ID \
161
+ -H "solidx-api-key: $API_KEY"
162
+ ```
163
+
164
+ `-d` makes the request a POST, and `-i` prints the status line so a 401 or 403 is
165
+ visible. `POST /api/agents/AGENT_ID/webhook` takes the same body but accepts only
166
+ an API key, never a JWT.
167
+
168
+ ## Test an agent interactively
169
+
170
+ Interactive chat follows the SolidX agent project's contract, so the same chat UI works against both
171
+ backends. The manager only signs the user in; the chat itself runs on the agent process.
172
+
173
+ 1. **Sign in (manager).** `POST /api/agent/api-keys/me` with `{"apiKey": "<Solid API key>", "agentId": <id>}`.
174
+ The manager checks the key with SolidX once, checks the user has a role linked to the agent, starts
175
+ (or picks) a process, hands it the entry for a new `agentToken`, and replies
176
+ `{"agentToken", "user": {"id", "username"}, "wsUrl", "httpUrl"}`. The API key never leaves the server.
177
+ 2. **Chat (process, WebSocket).** Connect to `wsUrl` (`.../ws/agent`). Every frame carries `agentToken`:
178
+ `start_session`, `resume_session` (with `session_id`), `message` (`session_id`, `content`),
179
+ `cancel`, `end_session`, `update_session` (acknowledged only) and `human_response`
180
+ (`session_id`, `request_id`, `choice`, `text`). The process answers with `session_started`,
181
+ the agent's events (`UserMessage`, `ToolCalling`, `LlmToken`, `TurnCompleteEvent`, ...), `turn_complete`,
182
+ `execution_cancelled`, `session_ended` and `error`.
183
+ 3. **Sessions (process, REST on `httpUrl`)**, each with `Authorization: Bearer <agentToken>`:
184
+ `GET /api/agent/sessions/history`, `GET /api/agent/sessions/{id}/messages?page=&page_size=`,
185
+ `DELETE /api/agent/sessions/{id}`, `DELETE /api/agent/api-keys/me` (sign out: forgets the token), plus the stand-ins `GET /api/agent/providers` and
186
+ `GET /api/agent/providers/{id}/models`.
187
+
188
+ After sign-in the browser never calls the manager again. If a process restarts, its entries are gone:
189
+ sign in again and resume the conversation with `resume_session` (it restores from the last checkpoint).
190
+
191
+ Settings: the manager needs `AGENTHUB_MANAGER_INTERNAL_TOKEN` (at least 32 random bytes; it signs the
192
+ handover to the process). `AGENTHUB_PUBLIC_HOST` and `AGENTHUB_PUBLIC_WS_SCHEME` build `wsUrl`, and a browser
193
+ must be able to reach the process ports (`AGENTHUB_PORT_RANGE`). Browsers on `localhost` or `127.0.0.1` (any port) may
194
+ always call the manager (sign-in) and the processes; `AGENTHUB_CORS_ORIGINS` (comma separated) adds other UI
195
+ origins. A token is forgotten after `AGENTHUB_AGENT_TOKEN_IDLE_SECONDS` (default 1800) without use.
196
+
197
+ With the manager running, `examples/ai-news-chat/index.html` is a small client for this, served at
198
+ `http://127.0.0.1:9000/chat/`. Enter the agent's numeric id and a Solid API key.
199
+
200
+ ## Use an agent from an MCP client
201
+
202
+ The MCP bridge lets Codex, Claude Code, Cursor, Copilot, VS Code, or Claude
203
+ Desktop use an AgentHub agent's instructions, skills, and tools. The client
204
+ reasons with your own subscription; AgentHub calls no model in this mode, so
205
+ the agent does not need a configured model. Details are in
206
+ `docs/technical-design.md` §19.
207
+
208
+ Start the manager with `AGENTHUB_MANAGER_INTERNAL_TOKEN` set to at least 32
209
+ random bytes (for example `openssl rand -hex 32`). Set
210
+ `AGENTHUB_MCP_PUBLIC_URL` and `AGENTHUB_MCP_ALLOWED_HOSTS` if clients reach
211
+ the manager through a name other than `127.0.0.1:9000`. Then list your agents
212
+ and get a client config:
213
+
214
+ ```sh
215
+ export AGENTHUB_MCP_TOKEN="$TOKEN" # SolidX JWT or API key
216
+ curl -s http://127.0.0.1:9000/api/mcp/agents -H "Authorization: Bearer $TOKEN"
217
+ curl -s "http://127.0.0.1:9000/api/mcp/agents/AGENT_ID/client-config?client=codex" \
218
+ -H "Authorization: Bearer $TOKEN"
219
+ ```
220
+
221
+ For Codex, the returned config is:
222
+
223
+ ```toml
224
+ [mcp_servers.agenthub]
225
+ url = "http://127.0.0.1:9000/mcp/agents/AGENT_ID"
226
+ bearer_token_env_var = "AGENTHUB_MCP_TOKEN"
227
+ tool_timeout_sec = 150
228
+ ```
229
+
230
+ Keep tokens in environment variables, not in checked-in config files. Tools
231
+ that need approval either prompt in the client (clients that support MCP
232
+ elicitation on protocol 2026-07-28) or return a `request_id`: approve it with
233
+ `POST /api/human-requests/{id}/answer`, then ask the client to call
234
+ `agenthub_get_request_result`. Every MCP request is audited in
235
+ `ss_mcp_audit_log`; read your session's log with
236
+ `GET /api/sessions/{id}/mcp-calls`.
237
+
238
+ For a disk agent without SolidX, run an unauthenticated local server:
239
+
240
+ ```sh
241
+ agenthub-runtime mcp examples/osv-review # stdio
242
+ agenthub-runtime mcp examples/osv-review --transport http --port 9200
243
+ ```
244
+
245
+ For stdio clients, configure the command `agenthub-runtime` with arguments
246
+ `mcp /absolute/path/to/agent`.
247
+
248
+ ## Deployment boundaries
249
+
250
+ For third-party JavaScript/React chat embedding, server-side token exchange, hosting requirements
251
+ and manual validation steps, see [Agent embedding](docs/AGENT-EMBEDDING.md).
252
+
253
+ - The SolidX module owns registry CRUD, tables/migrations, UI integration, and
254
+ `config_version` bump triggers. This runtime does not create production tables.
255
+ - Custom tools run in the agent process with its DB access and linked secrets.
256
+ The AST guard and isolated *validation subprocess* are **not a sandbox**.
257
+ Link only reviewed tools. A timed-out synchronous tool may continue running
258
+ in a worker thread.
259
+ - Plain model keys in the SolidX settings row, direct process WebSockets,
260
+ outbound `curl`/HTTP, and single-host placement remain first-cut limitations.
261
+ - `docs/module-contract.md` includes settings-table and SolidX REST path
262
+ assumptions that need confirmation with the separate module before a
263
+ production integration test. No real credentials or provider calls are used
264
+ by the tests.
265
+ - `scripts/load_smoke.py` measures local RSS and 4/8/16/32 concurrent mock
266
+ turns; it does not replace Postgres, real-provider, cold-spawn, or callback
267
+ load tests in the technical design.
@@ -0,0 +1,49 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "solidx-agenthub-runtime"
7
+ version = "0.1.0"
8
+ description = "SolidX AgentHub agent loop and runtime"
9
+ requires-python = ">=3.11"
10
+ dependencies = [
11
+ "mini-swe-agent>=2.4.6,<2.5",
12
+ "litellm",
13
+ "pydantic>=2",
14
+ "pyyaml",
15
+ "fastapi",
16
+ "uvicorn[standard]",
17
+ "sqlalchemy>=2,<2.1",
18
+ "psycopg2-binary",
19
+ "cryptography",
20
+ "requests",
21
+ "boto3",
22
+ "typer",
23
+ "mcp>=2.2,<2.3",
24
+ "httpx",
25
+ ]
26
+
27
+ [project.optional-dependencies]
28
+ dev = ["pytest", "pytest-asyncio", "httpx", "ruff", "build"]
29
+
30
+ [project.scripts]
31
+ agenthub-runtime = "agenthub.cli:app"
32
+
33
+ [tool.setuptools.packages.find]
34
+ where = ["src"]
35
+
36
+ [tool.setuptools.package-data]
37
+ agenthub = ["builtin_catalog.json"]
38
+ "agenthub.skills" = ["builtin/*.md"]
39
+
40
+ [tool.pytest.ini_options]
41
+ testpaths = ["tests"]
42
+ pythonpath = ["src"]
43
+
44
+ [tool.ruff]
45
+ target-version = "py311"
46
+ line-length = 120
47
+
48
+ [tool.ruff.lint]
49
+ select = ["E4", "E7", "E9", "F"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """SolidX AgentHub runtime."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1 @@
1
+ """LLM-free, session-scoped MCP access to AgentHub tools."""
@@ -0,0 +1,72 @@
1
+ """Bounded, best-effort audit writes; never capture HTTP streams."""
2
+
3
+ import asyncio
4
+ import json
5
+ import logging
6
+ import os
7
+ from datetime import timedelta
8
+
9
+ from sqlalchemy import delete
10
+
11
+ from agenthub.persistence.models import McpAuditLog, naive_now
12
+
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ def payload(value):
18
+ if value is None or os.getenv("AGENTHUB_MCP_AUDIT_PAYLOADS", "true").lower() != "true":
19
+ return None
20
+ text = value if isinstance(value, str) else json.dumps(value, default=str)
21
+ limit = max(0, int(os.getenv("AGENTHUB_MCP_AUDIT_MAX_CHARS", "100000")))
22
+ return text if len(text) <= limit else text[:limit] + "...[truncated]"
23
+
24
+
25
+ class AuditWriter:
26
+ def __init__(self, repo):
27
+ self.repo = repo
28
+ self.tasks = set()
29
+
30
+ def write(self, values):
31
+ # Map the bridge's audit fields onto Core's ``ss_mcp_audit_log`` columns.
32
+ code, message = values.get("error_code"), values.get("error_message")
33
+ if code is not None and not str(code).isdigit():
34
+ message, code = message or str(code), None
35
+ user_id = values.get("user_id")
36
+ row = McpAuditLog(
37
+ user_id=int(user_id) if str(user_id).isdigit() else None,
38
+ username=str(values["username"])[:128] if values.get("username") else None,
39
+ transport="streamable-http", mcp_session_id=values.get("session_id"),
40
+ client_addr=values.get("client_addr"), method=values["rpc_method"],
41
+ request_id=values.get("rpc_request_id"), tool_name=values.get("tool_name"),
42
+ request_params=values.get("request_params"), status=values["status"],
43
+ response_result=values.get("response_result"),
44
+ error_code=int(code) if code is not None else None, error_message=message,
45
+ duration_ms=values.get("duration_ms"))
46
+ with self.repo.transaction() as db:
47
+ db.add(row)
48
+
49
+ async def _write(self, values):
50
+ try:
51
+ await asyncio.to_thread(self.write, values)
52
+ except Exception:
53
+ logger.warning("MCP audit write failed")
54
+
55
+ def log(self, **values):
56
+ # Back pressure drops audit entries, not tool responses.
57
+ if len(self.tasks) >= 1000:
58
+ logger.warning("MCP audit queue full")
59
+ return
60
+ task = asyncio.create_task(self._write(values))
61
+ self.tasks.add(task)
62
+ task.add_done_callback(self.tasks.discard)
63
+
64
+ async def close(self):
65
+ if self.tasks:
66
+ await asyncio.gather(*list(self.tasks))
67
+
68
+ def purge(self):
69
+ days = int(os.getenv("AGENTHUB_MCP_AUDIT_RETENTION_DAYS", "0"))
70
+ if days > 0:
71
+ with self.repo.transaction() as db:
72
+ db.execute(delete(McpAuditLog).where(McpAuditLog.created_at < naive_now() - timedelta(days=days)))
@@ -0,0 +1,98 @@
1
+ """Bearer authentication and workspace binding for each HTTP request."""
2
+
3
+ import asyncio
4
+ import hashlib
5
+ import re
6
+ from dataclasses import dataclass, field
7
+
8
+ from starlette.responses import JSONResponse
9
+
10
+ from agenthub.bridge.sessions import resolve
11
+
12
+
13
+ @dataclass
14
+ class BridgeContext:
15
+ agent_id: int | str
16
+ principal: object
17
+ session: object
18
+ explicit: bool = False
19
+ credential_kind: str = "jwt"
20
+ credential_fingerprint: str = ""
21
+ client_addr: str | None = None
22
+ process_id: str | None = None
23
+ client_info: dict = field(default_factory=dict)
24
+ audit_arguments: object = None
25
+ audit_response: object = None
26
+ human_request_id: str | None = None
27
+
28
+ def clean(self, value):
29
+ import json
30
+ text = json.dumps(value, default=str)
31
+ for credential in (self.principal.access_token, self.principal.api_key):
32
+ if credential:
33
+ text = text.replace(credential, "[REDACTED]")
34
+ return json.loads(text)
35
+
36
+
37
+ class BridgeAuth:
38
+ def __init__(self, app, manager, audit):
39
+ self.app, self.manager, self.audit = app, manager, audit
40
+
41
+ async def __call__(self, scope, receive, send):
42
+ if scope["type"] != "http":
43
+ await self.app(scope, receive, send)
44
+ return
45
+ match = re.fullmatch(r"/mcp/agents/([^/]+)(?:/sessions/([^/]+))?/?", scope["path"])
46
+ if not match:
47
+ await JSONResponse({"error": "MCP endpoint not found"}, 404)(scope, receive, send)
48
+ return
49
+ agent_id, sid = match.groups()
50
+ try:
51
+ agent_id = int(agent_id)
52
+ except ValueError:
53
+ await JSONResponse({"error": "MCP endpoint not found"}, 404)(scope, receive, send)
54
+ return
55
+ headers = dict(scope.get("headers", ()))
56
+ auth = headers.get(b"authorization", b"").decode("latin-1")
57
+ api_key = headers.get(b"solidx-api-key", b"").decode("latin-1") or None
58
+ token = auth[7:].strip() if auth.startswith("Bearer ") else None
59
+ principal = None
60
+ kind = "api_key" if api_key else "jwt"
61
+ status, message = 401, "Invalid credentials"
62
+ try:
63
+ if token and not api_key:
64
+ try:
65
+ principal = await asyncio.to_thread(self.manager.identity.validate, token=token)
66
+ except Exception:
67
+ # Bearer API keys are a compatibility extension, not OAuth.
68
+ principal = await asyncio.to_thread(self.manager.identity.validate, api_key=token)
69
+ kind = "api_key"
70
+ elif api_key and not token:
71
+ principal = await asyncio.to_thread(self.manager.identity.validate, api_key=api_key)
72
+ else:
73
+ raise PermissionError("Missing or ambiguous credential")
74
+ except Exception:
75
+ pass
76
+ if principal:
77
+ try:
78
+ session = await asyncio.to_thread(resolve, self.manager.repo, agent_id, principal, sid)
79
+ except PermissionError:
80
+ status, message = 403, "Agent or session access denied"
81
+ except LookupError:
82
+ status, message = 404, "Agent or session unavailable"
83
+ else:
84
+ scoped = BridgeContext(
85
+ agent_id, principal, session, bool(sid), kind,
86
+ hashlib.sha256((api_key or token).encode()).hexdigest()[:16],
87
+ (scope.get("client") or (None,))[0],
88
+ )
89
+ inner = dict(scope)
90
+ inner["bridge_context"] = scoped
91
+ inner["path"], inner["raw_path"], inner["root_path"] = "/", b"/", ""
92
+ await self.app(inner, receive, send)
93
+ return
94
+ self.audit.log(agent_id=agent_id, user_id=principal.id if principal else None,
95
+ rpc_method="auth.denied", status="denied", error_code=str(status),
96
+ error_message=message, client_addr=(scope.get("client") or (None,))[0])
97
+ await JSONResponse({"error": message}, status, headers={"WWW-Authenticate": "Bearer"} if status == 401
98
+ else {})(scope, receive, send)
@@ -0,0 +1,123 @@
1
+ """Disk-mode MCP backend: one local user, in-process tools, prompt-only approvals."""
2
+
3
+ import asyncio
4
+ import json
5
+ import os
6
+ from datetime import datetime, timedelta, timezone
7
+ from threading import RLock
8
+ from types import SimpleNamespace
9
+ from uuid import uuid4
10
+
11
+ from agenthub.bridge.auth import BridgeContext
12
+ from agenthub.bridge.tool_session import PROMPT_APPROVALS, ToolSession, catalog
13
+ from agenthub.tools.executor import ParkSession
14
+ from agenthub.tools.loader import ToolRegistry
15
+
16
+ LOCAL_PRINCIPAL = SimpleNamespace(id="local", username="local", role_ids=(), access_token=None, api_key=None)
17
+
18
+
19
+ class LocalAudit:
20
+ def __init__(self, store=None):
21
+ self.store = store
22
+ if store:
23
+ with store.lock, store.db:
24
+ store.db.execute("CREATE TABLE IF NOT EXISTS mcp_calls "
25
+ "(id INTEGER PRIMARY KEY, session_id TEXT, data TEXT, created_at TEXT)")
26
+
27
+ def log(self, **values):
28
+ if not self.store:
29
+ return
30
+ with self.store.lock, self.store.db:
31
+ self.store.db.execute("INSERT INTO mcp_calls (session_id, data, created_at) VALUES (?, ?, ?)",
32
+ (values.get("session_id"), json.dumps(values, default=str),
33
+ datetime.now(timezone.utc).isoformat()))
34
+
35
+ async def close(self):
36
+ return None
37
+
38
+
39
+ class LocalBridge:
40
+ # No AgentHub UI exists in disk mode, so approvals must arrive through MCP elicitation.
41
+ out_of_band_approvals = False
42
+
43
+ def __init__(self, spec, store=None):
44
+ self.spec = spec
45
+ self.registry = ToolRegistry(spec.tools)
46
+ self.audit = LocalAudit(store)
47
+ self.lock = RLock()
48
+ self.pending = {}
49
+ self.session = self._new_session()
50
+
51
+ def _new_session(self):
52
+ return ToolSession(self.spec, self.registry, str(uuid4()))
53
+
54
+ def _principal(self):
55
+ return {"id": LOCAL_PRINCIPAL.id, "username": LOCAL_PRINCIPAL.username, "role_ids": ()}
56
+
57
+ def scope(self, ctx):
58
+ # The audit middleware and handlers must share one mutable scope per request.
59
+ scope = getattr(ctx, "_agenthub_scope", None)
60
+ if scope is None:
61
+ scope = BridgeContext(self.spec.agent_id, LOCAL_PRINCIPAL, SimpleNamespace(id=self.session.id),
62
+ credential_kind="local")
63
+ ctx._agenthub_scope = scope
64
+ return scope
65
+
66
+ async def catalog(self, scope):
67
+ return self.session.redact(catalog(self.spec, self.registry, PROMPT_APPROVALS), {})
68
+
69
+ async def call(self, scope, name, arguments):
70
+ session = self.session
71
+ action = {"tool_name": name, "arguments": arguments, "tool_call_id": str(uuid4())}
72
+ redacted = session.redact(arguments, self._principal())
73
+ scope.audit_arguments = redacted
74
+ try:
75
+ output = await asyncio.to_thread(session.call, action, self._principal())
76
+ except ParkSession as pause:
77
+ rid = str(uuid4())
78
+ ttl = int(os.getenv("AGENTHUB_MCP_APPROVAL_TTL", "900"))
79
+ with self.lock:
80
+ self.pending = {k: v for k, v in self.pending.items()
81
+ if v["expires_at"] > datetime.now(timezone.utc)}
82
+ self.pending[rid] = {"session": session, "action": action, "status": "pending",
83
+ "expires_at": datetime.now(timezone.utc) + timedelta(seconds=ttl)}
84
+ scope.human_request_id = rid
85
+ return {"status": "approval_required", "request_id": rid, "question": pause.question}
86
+ return {"status": "error" if output["returncode"] else "ok", "output": output["output"]}
87
+
88
+ async def answer(self, scope, rid, approve):
89
+ with self.lock:
90
+ request = self.pending.get(rid)
91
+ if not request or request["session"] is not self.session or request["status"] != "pending":
92
+ raise PermissionError("Invalid approval request")
93
+ request["status"] = "approved" if approve else "rejected"
94
+
95
+ async def resume(self, scope, rid):
96
+ scope.human_request_id = rid
97
+ with self.lock:
98
+ request = self.pending.get(rid)
99
+ if not request or request["session"] is not self.session:
100
+ return {"status": "error", "output": '{"error":"Request not found"}'}
101
+ if "result" in request:
102
+ return request["result"]
103
+ if request["status"] == "pending" and datetime.now(timezone.utc) >= request["expires_at"]:
104
+ request["status"] = "expired"
105
+ if request["status"] != "approved":
106
+ return {"status": request["status"]}
107
+ request["status"] = "executing"
108
+ result = await asyncio.to_thread(request["session"].call, request["action"], self._principal(),
109
+ approved=True)
110
+ output = {"status": "error" if result["returncode"] else "ok", "output": result["output"]}
111
+ with self.lock:
112
+ request["status"], request["result"] = "executed", output
113
+ return output
114
+
115
+ async def rotate(self, scope):
116
+ with self.lock:
117
+ self.session = self._new_session()
118
+ self.pending.clear()
119
+ scope.session = SimpleNamespace(id=self.session.id)
120
+ return {"status": "ok", "output": json.dumps({"session_id": self.session.id})}
121
+
122
+ async def close(self):
123
+ return None