helixor-agent-sdk 0.4.33__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 (66) hide show
  1. helixor_agent_sdk-0.4.33/PKG-INFO +25 -0
  2. helixor_agent_sdk-0.4.33/README.md +15 -0
  3. helixor_agent_sdk-0.4.33/pyproject.toml +17 -0
  4. helixor_agent_sdk-0.4.33/setup.cfg +4 -0
  5. helixor_agent_sdk-0.4.33/src/helixor_agent/__init__.py +16 -0
  6. helixor_agent_sdk-0.4.33/src/helixor_agent/__main__.py +5 -0
  7. helixor_agent_sdk-0.4.33/src/helixor_agent/bridge/agent-bridge.mjs +395 -0
  8. helixor_agent_sdk-0.4.33/src/helixor_agent/bundles.py +66 -0
  9. helixor_agent_sdk-0.4.33/src/helixor_agent/cli.py +1393 -0
  10. helixor_agent_sdk-0.4.33/src/helixor_agent/client.py +132 -0
  11. helixor_agent_sdk-0.4.33/src/helixor_agent/event_sources.py +77 -0
  12. helixor_agent_sdk-0.4.33/src/helixor_agent/folder.py +730 -0
  13. helixor_agent_sdk-0.4.33/src/helixor_agent/group.py +338 -0
  14. helixor_agent_sdk-0.4.33/src/helixor_agent/invoke.py +885 -0
  15. helixor_agent_sdk-0.4.33/src/helixor_agent/local_config.py +10 -0
  16. helixor_agent_sdk-0.4.33/src/helixor_agent/local_model.py +150 -0
  17. helixor_agent_sdk-0.4.33/src/helixor_agent/rules.py +251 -0
  18. helixor_agent_sdk-0.4.33/src/helixor_agent/service_client/__init__.py +7 -0
  19. helixor_agent_sdk-0.4.33/src/helixor_agent/service_client/_operations/__init__.py +29 -0
  20. helixor_agent_sdk-0.4.33/src/helixor_agent/service_client/_operations/agent_operations.py +59 -0
  21. helixor_agent_sdk-0.4.33/src/helixor_agent/service_client/_operations/management_operations_part_1.py +304 -0
  22. helixor_agent_sdk-0.4.33/src/helixor_agent/service_client/_operations/management_operations_part_2.py +248 -0
  23. helixor_agent_sdk-0.4.33/src/helixor_agent/service_client/_operations/owner_operations.py +73 -0
  24. helixor_agent_sdk-0.4.33/src/helixor_agent/service_client/client.py +223 -0
  25. helixor_agent_sdk-0.4.33/src/helixor_agent/service_client/contract.py +97 -0
  26. helixor_agent_sdk-0.4.33/src/helixor_agent/service_client/errors.py +46 -0
  27. helixor_agent_sdk-0.4.33/src/helixor_agent/service_client/models.py +2328 -0
  28. helixor_agent_sdk-0.4.33/src/helixor_agent/service_client/operation_contracts.py +79 -0
  29. helixor_agent_sdk-0.4.33/src/helixor_agent/starters.py +100 -0
  30. helixor_agent_sdk-0.4.33/src/helixor_agent/templates/expense-checker/agent.yaml +27 -0
  31. helixor_agent_sdk-0.4.33/src/helixor_agent/templates/expense-checker/skills/check-expense/SKILL.md +9 -0
  32. helixor_agent_sdk-0.4.33/src/helixor_agent/templates/expense-checker/skills/file-expense/SKILL.md +6 -0
  33. helixor_agent_sdk-0.4.33/src/helixor_agent/templates/expense-checker/tools.py +49 -0
  34. helixor_agent_sdk-0.4.33/src/helixor_agent/templates/meeting-scheduler/agent.yaml +27 -0
  35. helixor_agent_sdk-0.4.33/src/helixor_agent/templates/meeting-scheduler/skills/book/SKILL.md +5 -0
  36. helixor_agent_sdk-0.4.33/src/helixor_agent/templates/meeting-scheduler/skills/find-time/SKILL.md +5 -0
  37. helixor_agent_sdk-0.4.33/src/helixor_agent/templates/meeting-scheduler/tools.py +60 -0
  38. helixor_agent_sdk-0.4.33/src/helixor_agent/templates/refund-helper/agent.yaml +28 -0
  39. helixor_agent_sdk-0.4.33/src/helixor_agent/templates/refund-helper/skills/order-status/SKILL.md +6 -0
  40. helixor_agent_sdk-0.4.33/src/helixor_agent/templates/refund-helper/skills/refunds/SKILL.md +9 -0
  41. helixor_agent_sdk-0.4.33/src/helixor_agent/templates/refund-helper/tools.py +54 -0
  42. helixor_agent_sdk-0.4.33/src/helixor_agent/templates/research-assistant/agent.yaml +28 -0
  43. helixor_agent_sdk-0.4.33/src/helixor_agent/templates/research-assistant/skills/research/SKILL.md +6 -0
  44. helixor_agent_sdk-0.4.33/src/helixor_agent/templates/research-assistant/skills/write-brief/SKILL.md +6 -0
  45. helixor_agent_sdk-0.4.33/src/helixor_agent/templates/research-assistant/tools.py +37 -0
  46. helixor_agent_sdk-0.4.33/src/helixor_agent/templates/support-assistant/agent.yaml +28 -0
  47. helixor_agent_sdk-0.4.33/src/helixor_agent/templates/support-assistant/skills/answer-from-docs/SKILL.md +7 -0
  48. helixor_agent_sdk-0.4.33/src/helixor_agent/templates/support-assistant/skills/open-ticket/SKILL.md +7 -0
  49. helixor_agent_sdk-0.4.33/src/helixor_agent/templates/support-assistant/tools.py +40 -0
  50. helixor_agent_sdk-0.4.33/src/helixor_agent/tool.py +100 -0
  51. helixor_agent_sdk-0.4.33/src/helixor_agent/tool_state.py +57 -0
  52. helixor_agent_sdk-0.4.33/src/helixor_agent/work_lists.py +295 -0
  53. helixor_agent_sdk-0.4.33/src/helixor_agent/yaml_text.py +31 -0
  54. helixor_agent_sdk-0.4.33/src/helixor_agent_sdk.egg-info/PKG-INFO +25 -0
  55. helixor_agent_sdk-0.4.33/src/helixor_agent_sdk.egg-info/SOURCES.txt +64 -0
  56. helixor_agent_sdk-0.4.33/src/helixor_agent_sdk.egg-info/dependency_links.txt +1 -0
  57. helixor_agent_sdk-0.4.33/src/helixor_agent_sdk.egg-info/requires.txt +3 -0
  58. helixor_agent_sdk-0.4.33/src/helixor_agent_sdk.egg-info/top_level.txt +2 -0
  59. helixor_agent_sdk-0.4.33/src/helixor_exec_pool_client/__init__.py +7 -0
  60. helixor_agent_sdk-0.4.33/src/helixor_exec_pool_client/_operations/__init__.py +16 -0
  61. helixor_agent_sdk-0.4.33/src/helixor_exec_pool_client/_operations/pool_operations.py +64 -0
  62. helixor_agent_sdk-0.4.33/src/helixor_exec_pool_client/client.py +221 -0
  63. helixor_agent_sdk-0.4.33/src/helixor_exec_pool_client/contract.py +19 -0
  64. helixor_agent_sdk-0.4.33/src/helixor_exec_pool_client/errors.py +46 -0
  65. helixor_agent_sdk-0.4.33/src/helixor_exec_pool_client/models.py +223 -0
  66. helixor_agent_sdk-0.4.33/src/helixor_exec_pool_client/operation_contracts.py +79 -0
@@ -0,0 +1,25 @@
1
+ Metadata-Version: 2.4
2
+ Name: helixor-agent-sdk
3
+ Version: 0.4.33
4
+ Summary: Public Helixor agent authoring, client SDK, CLI and Pi bridge.
5
+ Requires-Python: >=3.11
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: httpx>=0.27
8
+ Requires-Dist: pydantic>=2.0
9
+ Requires-Dist: pyyaml>=6.0
10
+
11
+ # Helixor agent SDK
12
+
13
+ Public authoring, CLI commands, generated clients, templates and Pi bridge.
14
+ The `helixor` CLI depends on this package. It contains no agent service,
15
+ pricing/usage engine, draft store or authoring assistant prompts.
16
+
17
+ `helixor agent check` validates local structure. Deployment admission on the
18
+ selected service validates prices, budgets and policy rules before execution.
19
+ Python callers can request structural checks with `load_agent(path,
20
+ check_prices=False)`; the default price admission requires the private runtime.
21
+
22
+ A shared deployment is cloud hosted (development or production) and uses its
23
+ team/tenant authorization. A local deployment is the user's personal sandbox.
24
+ Select the authorized cloud service with `--to`; local execution requires the
25
+ matching private runtime and never grants cloud permissions.
@@ -0,0 +1,15 @@
1
+ # Helixor agent SDK
2
+
3
+ Public authoring, CLI commands, generated clients, templates and Pi bridge.
4
+ The `helixor` CLI depends on this package. It contains no agent service,
5
+ pricing/usage engine, draft store or authoring assistant prompts.
6
+
7
+ `helixor agent check` validates local structure. Deployment admission on the
8
+ selected service validates prices, budgets and policy rules before execution.
9
+ Python callers can request structural checks with `load_agent(path,
10
+ check_prices=False)`; the default price admission requires the private runtime.
11
+
12
+ A shared deployment is cloud hosted (development or production) and uses its
13
+ team/tenant authorization. A local deployment is the user's personal sandbox.
14
+ Select the authorized cloud service with `--to`; local execution requires the
15
+ matching private runtime and never grants cloud permissions.
@@ -0,0 +1,17 @@
1
+ [build-system]
2
+ requires = ["setuptools>=69", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "helixor-agent-sdk"
7
+ version = "0.4.33"
8
+ description = "Public Helixor agent authoring, client SDK, CLI and Pi bridge."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ dependencies = ["httpx>=0.27", "pydantic>=2.0", "pyyaml>=6.0"]
12
+
13
+ [tool.setuptools.packages.find]
14
+ where = ["src"]
15
+
16
+ [tool.setuptools.package-data]
17
+ helixor_agent = ["bridge/*.mjs", "templates/*/agent.yaml", "templates/*/tools.py", "templates/*/skills/*/SKILL.md"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,16 @@
1
+ """Helixor agents: a folder of instructions, skills and tools, run on Pi.
2
+
3
+ from helixor_agent import tool # mark a function in tools.py
4
+ from helixor_agent import Agent # call a deployed agent
5
+ from helixor_agent import state_dir # files a tool keeps between calls (runtime: {state: ...})
6
+ """
7
+
8
+ from pkgutil import extend_path
9
+
10
+ __path__ = extend_path(__path__, __name__)
11
+
12
+ from helixor_agent.client import Agent, AgentCallError, AgentReply
13
+ from helixor_agent.tool import tool
14
+ from helixor_agent.tool_state import state_dir
15
+
16
+ __all__ = ["Agent", "AgentCallError", "AgentReply", "state_dir", "tool"]
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from helixor_agent.cli import main
4
+
5
+ sys.exit(main())
@@ -0,0 +1,395 @@
1
+ // Helixor agent bridge: runs one Pi agent session for one conversation.
2
+ //
3
+ // Protocol: JSON lines. stdin carries requests from the Python side, stdout
4
+ // carries replies. Nothing else may write to stdout, so console output goes to
5
+ // stderr.
6
+ //
7
+ // -> {"type":"init","agent":{...},"model_api_key":"...","session_file":"..."|null,"session_dir":"...",
8
+ // "local_model":{"base_url":"http://127.0.0.1:8860/v1","context_window":32768,"max_tokens":4096}|null}
9
+ // <- {"type":"ready","session_file":"...","skills":[...],"diagnostics":[...]}
10
+ // -> {"type":"message","text":"..."}
11
+ // <- {"type":"tool_call","id":"...","name":"...","args":{...}}
12
+ // -> {"type":"tool_result","id":"...","ok":true,"result":...} | {"ok":false,"error":"..."}
13
+ // <- {"type":"tool_rejected","name":"...","args":{...},"error":"..."} (never reached Python: bad arguments
14
+ // or a blocked tool; the model sees the error)
15
+ // <- {"type":"model_call_request","id":"m1","provider":"...","model":"...","prompt_chars":N,
16
+ // "max_tokens":N} (before EVERY model call; nothing is sent until
17
+ // -> {"type":"model_call_decision","id":"m1","ok":true} | {"ok":false,"code":"...","detail":"..."} Python answers)
18
+ // <- {"type":"model_call","id":"m1","provider","model","latency_ms","max_tokens","stop_reason","error",
19
+ // "usage":{"input","output","cache_read","cache_write","total"},"raw_usage":{...provider fields...},"pi_cost_usd"}
20
+ // <- {"type":"reply","text":"...","tool_calls":[{"tool","args","ok"}],"skills_used":["..."],
21
+ // "usage":{"input","output","cache_read","cache_write","total","model_calls"}}
22
+ // <- {"type":"stopped","code":"...","detail":"...","tool_calls":[...],"usage":{...}} (a model call was refused:
23
+ // the turn ends, the model was not called)
24
+ // <- {"type":"error","code":"...","detail":"..."}
25
+ //
26
+ // Metering: every model call goes through meteredStream, which asks Python first (budgets, the usage ledger)
27
+ // and reports the call's tokens, latency and the provider's own usage fields after it. Pi's cost figure is
28
+ // passed along as a cross-check only; Python prices calls from its own versioned table.
29
+ //
30
+ // Pi's built-in tools are off except `read`, and `read` may open files only
31
+ // under the agent's skills folder: that is how Pi loads a skill when a task
32
+ // matches its description. The agent's own tools run on the Python side; the
33
+ // bridge only forwards calls and results, so a tool can never be faked here.
34
+ //
35
+ // A `local` provider is an OpenAI-compatible chat completions server on loopback (model:
36
+ // local/<name> in agent.yaml). It is registered here as a custom Pi provider with the
37
+ // openai-completions API; its placeholder key never leaves loopback.
38
+
39
+ import { createInterface } from 'node:readline'
40
+ import { resolve, sep } from 'node:path'
41
+ import { mkdirSync } from 'node:fs'
42
+
43
+ import {
44
+ createAgentSession,
45
+ DefaultResourceLoader,
46
+ ModelRuntime,
47
+ SessionManager,
48
+ SettingsManager,
49
+ } from '@earendil-works/pi-coding-agent'
50
+ import { getModel } from '@earendil-works/pi-ai/compat'
51
+
52
+ console.log = (...args) => console.error(...args)
53
+ console.info = (...args) => console.error(...args)
54
+
55
+ function emit(obj) {
56
+ process.stdout.write(JSON.stringify(obj) + '\n')
57
+ }
58
+
59
+ function fail(code, detail) {
60
+ emit({ type: 'error', code, detail: String(detail ?? '') })
61
+ }
62
+
63
+ let session = null
64
+ let pending = new Map()
65
+ let callSeq = 0
66
+ let turnCalls = null
67
+ let turnSkills = null
68
+ let busy = false
69
+ let forwarded = new Set()
70
+ let started = new Map()
71
+ let modelSeq = 0
72
+ let decisions = new Map()
73
+ let stopped = null
74
+ let providerUsage = null
75
+ let maxOutputTokens = null
76
+ const LOOPBACK = new Set(['127.0.0.1', '::1', '[::1]', 'localhost'])
77
+
78
+ function isLoopback(url) {
79
+ try {
80
+ const u = new URL(url)
81
+ return (u.protocol === 'http:' || u.protocol === 'https:') && !u.username && !u.password &&
82
+ (LOOPBACK.has(u.hostname) || /^127\.\d+\.\d+\.\d+$/.test(u.hostname))
83
+ } catch {
84
+ return false
85
+ }
86
+ }
87
+
88
+ // The Pi model for `local/<name>`: openai-completions against the loopback server, tools on,
89
+ // thinking requested off per call through the chat template (enable_thinking), no key needed.
90
+ function localModelConfig(name, local, apiKey) {
91
+ return {
92
+ name: 'Local open-weight model (loopback)',
93
+ baseUrl: local.base_url,
94
+ apiKey,
95
+ api: 'openai-completions',
96
+ models: [
97
+ {
98
+ id: name,
99
+ name: `local/${name}`,
100
+ api: 'openai-completions',
101
+ reasoning: true,
102
+ input: ['text'],
103
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
104
+ contextWindow: local.context_window,
105
+ maxTokens: local.max_tokens,
106
+ compat: {
107
+ supportsStore: false,
108
+ supportsDeveloperRole: false,
109
+ supportsReasoningEffort: false,
110
+ supportsUsageInStreaming: true,
111
+ maxTokensField: 'max_tokens',
112
+ thinkingFormat: 'chat-template',
113
+ chatTemplateKwargs: { enable_thinking: { $var: 'thinking.enabled' } },
114
+ },
115
+ },
116
+ ],
117
+ }
118
+ }
119
+
120
+ function skillsGuard(skillsDir, allowed) {
121
+ const root = skillsDir ? resolve(skillsDir) + sep : null
122
+ return (pi) => {
123
+ pi.on('provider_stream_event', async (event) => {
124
+ // The provider's own usage fields, as it reported them (Anthropic message_start / message_delta, OpenAI chunks).
125
+ const data = event.data ?? {}
126
+ const usage = data.usage ?? data.message?.usage
127
+ if (providerUsage && usage && typeof usage === 'object') Object.assign(providerUsage, usage)
128
+ })
129
+ pi.on('tool_call', async (event) => {
130
+ if (!allowed.has(event.toolName)) {
131
+ return { block: true, reason: `TOOL_NOT_ALLOWED: ${event.toolName} is not one of this agent's tools` }
132
+ }
133
+ if (event.toolName === 'read') {
134
+ const target = resolve(skillsDir || '/', String(event.input?.path ?? ''))
135
+ if (!root || !(target + sep).startsWith(root)) {
136
+ return { block: true, reason: 'READ_OUTSIDE_SKILLS: read may only open this agent\'s skill files' }
137
+ }
138
+ const skill = target.slice(root.length).split(sep)[0]
139
+ if (skill && !turnSkills?.includes(skill)) turnSkills?.push(skill)
140
+ }
141
+ return undefined
142
+ })
143
+ }
144
+ }
145
+
146
+ function agentTool(def) {
147
+ return {
148
+ name: def.name,
149
+ label: def.name,
150
+ description: def.description,
151
+ parameters: def.parameters,
152
+ execute: async (toolCallId, params) => {
153
+ forwarded.add(toolCallId)
154
+ const id = `c${++callSeq}`
155
+ const result = await new Promise((resolveCall) => {
156
+ pending.set(id, resolveCall)
157
+ emit({ type: 'tool_call', id, name: def.name, args: params ?? {} })
158
+ })
159
+ turnCalls?.push({ tool: def.name, args: params ?? {}, ok: !!result.ok, ...(result.ok ? {} : { error: result.error }) })
160
+ const body = result.ok ? result.result : { error: true, detail: result.error }
161
+ return {
162
+ content: [{ type: 'text', text: JSON.stringify(body) }],
163
+ details: body,
164
+ ...(result.ok ? {} : { isError: true }),
165
+ }
166
+ },
167
+ }
168
+ }
169
+
170
+ async function init(req) {
171
+ const agent = req.agent
172
+ if (!req.model_api_key) return fail('AGENT_MODEL_KEY_MISSING', `no API key for ${agent.provider}`)
173
+
174
+ const stateDir = req.state_dir
175
+ mkdirSync(stateDir, { recursive: true })
176
+ const modelRuntime = await ModelRuntime.create({
177
+ authPath: `${stateDir}/auth.json`,
178
+ modelsPath: `${stateDir}/models.json`,
179
+ })
180
+ let model
181
+ if (agent.provider === 'local') {
182
+ const local = req.local_model
183
+ if (!local?.base_url) return fail('LOCAL_MODEL_UNAVAILABLE', 'no endpoint for the local model')
184
+ if (!isLoopback(local.base_url)) return fail('LOCAL_MODEL_URL_NOT_LOOPBACK', local.base_url)
185
+ modelRuntime.registerProvider('local', localModelConfig(agent.model, local, req.model_api_key))
186
+ model = modelRuntime.getModel('local', agent.model)
187
+ if (!model) return fail('AGENT_MODEL_UNKNOWN', `local/${agent.model} could not be registered`)
188
+ } else {
189
+ model = getModel(agent.provider, agent.model)
190
+ if (!model) return fail('AGENT_MODEL_UNKNOWN', `${agent.provider}/${agent.model} is not a model this runtime knows`)
191
+ await modelRuntime.setRuntimeApiKey(agent.provider, req.model_api_key)
192
+ }
193
+
194
+ const settingsManager = SettingsManager.inMemory({
195
+ compaction: { enabled: true },
196
+ retry: { enabled: true, maxRetries: 1 },
197
+ enableInstallTelemetry: false,
198
+ enableAnalytics: false,
199
+ })
200
+
201
+ const toolNames = agent.tools.map((t) => t.name)
202
+ const hasSkills = (agent.skill_dirs ?? []).length > 0
203
+ const allowed = new Set([...toolNames, ...(hasSkills ? ['read'] : [])])
204
+
205
+ const resourceLoader = new DefaultResourceLoader({
206
+ cwd: agent.root,
207
+ agentDir: stateDir,
208
+ settingsManager,
209
+ noExtensions: true,
210
+ noSkills: true,
211
+ additionalSkillPaths: agent.skill_dirs ?? [],
212
+ noPromptTemplates: true,
213
+ noThemes: true,
214
+ noContextFiles: true,
215
+ systemPrompt: agent.instructions,
216
+ extensionFactories: [skillsGuard(agent.skills_root, allowed)],
217
+ })
218
+ await resourceLoader.reload()
219
+ const loaded = resourceLoader.getSkills()
220
+
221
+ let sessionManager
222
+ if (req.session_file) sessionManager = SessionManager.open(req.session_file, req.session_dir, agent.root)
223
+ else if (req.session_dir) sessionManager = SessionManager.create(agent.root, req.session_dir)
224
+ else sessionManager = SessionManager.inMemory(agent.root)
225
+
226
+ const created = await createAgentSession({
227
+ cwd: agent.root,
228
+ agentDir: stateDir,
229
+ model,
230
+ thinkingLevel: 'off',
231
+ modelRuntime,
232
+ resourceLoader,
233
+ settingsManager,
234
+ sessionManager,
235
+ tools: [...allowed],
236
+ customTools: agent.tools.map(agentTool),
237
+ })
238
+ session = created.session
239
+ maxOutputTokens = agent.max_output_tokens ?? null
240
+ session.agent.streamFunction = meteredStream(session.agent.streamFunction)
241
+ const ownTools = new Set(toolNames)
242
+ session.subscribe((event) => {
243
+ if (event.type === 'tool_execution_start') started.set(event.toolCallId, event.args)
244
+ if (event.type !== 'tool_execution_end') return
245
+ const args = started.get(event.toolCallId)
246
+ started.delete(event.toolCallId)
247
+ if (forwarded.delete(event.toolCallId) || !event.isError || !ownTools.has(event.toolName)) return
248
+ // Rejected before it reached Python (arguments that fail the tool's schema, or a blocked call):
249
+ // the model got the error as the tool result; Python records it.
250
+ const text = (event.result?.content ?? []).map((c) => c?.text ?? '').join(' ').trim()
251
+ turnCalls?.push({ tool: event.toolName, args: args ?? {}, ok: false, error: text })
252
+ emit({ type: 'tool_rejected', name: event.toolName, args: args ?? {}, error: text || 'rejected' })
253
+ })
254
+ emit({
255
+ type: 'ready',
256
+ session_file: session.sessionFile ?? null,
257
+ skills: (loaded.skills ?? []).map((s) => s.name),
258
+ diagnostics: (loaded.diagnostics ?? []).map((d) => d.message ?? String(d)),
259
+ })
260
+ }
261
+
262
+ function ask(request) {
263
+ return new Promise((resolveAsk) => {
264
+ decisions.set(request.id, resolveAsk)
265
+ emit(request)
266
+ })
267
+ }
268
+
269
+ function promptChars(context) {
270
+ try {
271
+ return JSON.stringify({ s: context?.systemPrompt ?? '', m: context?.messages ?? [], t: context?.tools ?? [] }).length
272
+ } catch {
273
+ return 0
274
+ }
275
+ }
276
+
277
+ // Every model call: ask Python first; on a refusal the model is NOT called (the turn ends with "stopped").
278
+ function meteredStream(original) {
279
+ return async (model, context, options) => {
280
+ const id = `m${++modelSeq}`
281
+ const maxTokens = maxOutputTokens ?? options?.maxTokens ?? model.maxTokens
282
+ const chars = promptChars(context)
283
+ const decision = await ask({
284
+ type: 'model_call_request', id, provider: model.provider, model: model.id,
285
+ prompt_chars: chars, max_tokens: maxTokens,
286
+ })
287
+ if (!decision.ok) {
288
+ stopped = stopped ?? { code: decision.code || 'AGENT_BUDGET_EXCEEDED', detail: decision.detail || '' }
289
+ throw new Error(`${stopped.code}: ${stopped.detail}`)
290
+ }
291
+ const raw = {}
292
+ providerUsage = raw
293
+ const t0 = Date.now()
294
+ const response = await original(model, context, { ...options, maxTokens })
295
+ response.result().then((m) => {
296
+ const u = m?.usage ?? {}
297
+ emit({
298
+ type: 'model_call', id, provider: model.provider, model: model.id, latency_ms: Date.now() - t0,
299
+ max_tokens: maxTokens, prompt_chars: chars, stop_reason: m?.stopReason ?? null,
300
+ error: m?.errorMessage ?? null,
301
+ usage: { input: u.input ?? 0, output: u.output ?? 0, cache_read: u.cacheRead ?? 0, cache_write: u.cacheWrite ?? 0,
302
+ total: u.totalTokens ?? 0 },
303
+ raw_usage: raw, pi_cost_usd: u.cost?.total ?? null,
304
+ })
305
+ }, (e) => {
306
+ emit({ type: 'model_call', id, provider: model.provider, model: model.id, latency_ms: Date.now() - t0,
307
+ max_tokens: maxTokens, error: String(e?.message ?? e), usage: {}, raw_usage: raw })
308
+ })
309
+ return response
310
+ }
311
+ }
312
+
313
+ function lastAssistant() {
314
+ const messages = session.messages ?? []
315
+ for (let i = messages.length - 1; i >= 0; i--) {
316
+ if (messages[i]?.role === 'assistant') return messages[i]
317
+ }
318
+ return null
319
+ }
320
+
321
+ function turnUsage(from) {
322
+ const out = { input: 0, output: 0, cache_read: 0, cache_write: 0, total: 0, model_calls: 0 }
323
+ for (const m of (session.messages ?? []).slice(from)) {
324
+ if (m?.role !== 'assistant' || !m.usage) continue
325
+ out.model_calls += 1
326
+ out.input += m.usage.input ?? 0
327
+ out.output += m.usage.output ?? 0
328
+ out.cache_read += m.usage.cacheRead ?? 0
329
+ out.cache_write += m.usage.cacheWrite ?? 0
330
+ out.total += m.usage.totalTokens ?? 0
331
+ }
332
+ return out
333
+ }
334
+
335
+ async function message(req) {
336
+ if (!session) return fail('AGENT_NOT_READY', 'init first')
337
+ if (busy) return fail('AGENT_BUSY', 'one message at a time per conversation')
338
+ busy = true
339
+ turnCalls = []
340
+ turnSkills = []
341
+ stopped = null
342
+ const before = (session.messages ?? []).length
343
+ try {
344
+ await session.prompt(String(req.text ?? ''))
345
+ if (stopped) {
346
+ return emit({ type: 'stopped', ...stopped, tool_calls: turnCalls, skills_used: turnSkills, usage: turnUsage(before) })
347
+ }
348
+ const last = lastAssistant()
349
+ if (last?.stopReason === 'error' || last?.errorMessage) {
350
+ return fail('AGENT_MODEL_ERROR', last.errorMessage || 'the model call failed')
351
+ }
352
+ const text = session.getLastAssistantText() ?? ''
353
+ if (!text.trim()) return fail('AGENT_EMPTY_REPLY', 'the model returned no text')
354
+ emit({ type: 'reply', text, tool_calls: turnCalls, skills_used: turnSkills, usage: turnUsage(before) })
355
+ } catch (error) {
356
+ if (stopped) emit({ type: 'stopped', ...stopped, tool_calls: turnCalls, skills_used: turnSkills, usage: turnUsage(before) })
357
+ else fail('AGENT_TURN_FAILED', error?.message ?? error)
358
+ } finally {
359
+ stopped = null
360
+ busy = false
361
+ turnCalls = null
362
+ turnSkills = null
363
+ }
364
+ }
365
+
366
+ const rl = createInterface({ input: process.stdin })
367
+ rl.on('line', (line) => {
368
+ if (!line.trim()) return
369
+ let req
370
+ try {
371
+ req = JSON.parse(line)
372
+ } catch {
373
+ return fail('BRIDGE_BAD_JSON', line.slice(0, 200))
374
+ }
375
+ if (req.type === 'model_call_decision') {
376
+ const done = decisions.get(req.id)
377
+ if (done) {
378
+ decisions.delete(req.id)
379
+ done(req)
380
+ }
381
+ return
382
+ }
383
+ if (req.type === 'tool_result') {
384
+ const done = pending.get(req.id)
385
+ if (done) {
386
+ pending.delete(req.id)
387
+ done(req)
388
+ }
389
+ return
390
+ }
391
+ if (req.type === 'init') return void init(req).catch((e) => fail('AGENT_INIT_FAILED', e?.message ?? e))
392
+ if (req.type === 'message') return void message(req)
393
+ fail('BRIDGE_UNKNOWN_REQUEST', req.type)
394
+ })
395
+ rl.on('close', () => process.exit(0))
@@ -0,0 +1,66 @@
1
+ """Deterministic application bundles shared by clients and services."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import io
7
+ import tarfile
8
+ from pathlib import Path
9
+
10
+ _BUNDLE_SKIP = {"__pycache__", ".git", ".helixor", ".venv", "node_modules"}
11
+
12
+ def _shipped(root: Path) -> list[Path]:
13
+ out = []
14
+ for path in sorted(root.rglob("*")):
15
+ rel = path.relative_to(root)
16
+ if any(part in _BUNDLE_SKIP or part.startswith(".") for part in rel.parts):
17
+ continue
18
+ if path.is_file():
19
+ out.append(path)
20
+ return out
21
+
22
+
23
+ def pack_folder(root: Path) -> bytes:
24
+ """The folder as a gzip tarball, without caches, VCS data or local keys.
25
+
26
+ Deterministic: the same files give the same bytes (no timestamps, owners or umask in the archive; a file is
27
+ 0644, or 0755 when executable), so a downloaded draft deployed from the CLI is byte-for-byte the bundle the
28
+ browser would deploy.
29
+ """
30
+ return pack_files(_shipped(root), root)
31
+
32
+
33
+ def bundle_sha256(data: bytes) -> str:
34
+ """The hash of a packed bundle (what ``pack_folder`` returns)."""
35
+ return hashlib.sha256(data).hexdigest()
36
+
37
+
38
+ def pack_files(paths: list[Path], root: Path) -> bytes:
39
+ """Some of a folder's files as a gzip tarball, the same way ``pack_folder`` packs them."""
40
+ import gzip
41
+
42
+ buf = io.BytesIO()
43
+ with gzip.GzipFile(filename="", fileobj=buf, mode="wb", mtime=0) as gz, \
44
+ tarfile.open(fileobj=gz, mode="w", format=tarfile.GNU_FORMAT) as tar:
45
+ for path in sorted(paths):
46
+ data = path.read_bytes()
47
+ info = tarfile.TarInfo(path.relative_to(root).as_posix())
48
+ info.size = len(data)
49
+ info.mode = 0o755 if path.stat().st_mode & 0o111 else 0o644
50
+ info.mtime, info.uid, info.gid, info.uname, info.gname = 0, 0, 0, "", ""
51
+ tar.addfile(info, io.BytesIO(data))
52
+ return buf.getvalue()
53
+
54
+
55
+ def unpack_bundle(data: bytes, dest: Path) -> None:
56
+ dest.mkdir(parents=True, exist_ok=False)
57
+ with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as tar:
58
+ tar.extractall(dest, filter="data")
59
+
60
+
61
+ def folder_digest(root: Path) -> str:
62
+ """Changes exactly when a shipped file's path or content changes."""
63
+ digest = hashlib.sha256()
64
+ for path in _shipped(root):
65
+ digest.update(str(path.relative_to(root)).encode() + b"\0" + hashlib.sha256(path.read_bytes()).digest())
66
+ return digest.hexdigest()[:20]