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.
- helixor_agent_sdk-0.4.33/PKG-INFO +25 -0
- helixor_agent_sdk-0.4.33/README.md +15 -0
- helixor_agent_sdk-0.4.33/pyproject.toml +17 -0
- helixor_agent_sdk-0.4.33/setup.cfg +4 -0
- helixor_agent_sdk-0.4.33/src/helixor_agent/__init__.py +16 -0
- helixor_agent_sdk-0.4.33/src/helixor_agent/__main__.py +5 -0
- helixor_agent_sdk-0.4.33/src/helixor_agent/bridge/agent-bridge.mjs +395 -0
- helixor_agent_sdk-0.4.33/src/helixor_agent/bundles.py +66 -0
- helixor_agent_sdk-0.4.33/src/helixor_agent/cli.py +1393 -0
- helixor_agent_sdk-0.4.33/src/helixor_agent/client.py +132 -0
- helixor_agent_sdk-0.4.33/src/helixor_agent/event_sources.py +77 -0
- helixor_agent_sdk-0.4.33/src/helixor_agent/folder.py +730 -0
- helixor_agent_sdk-0.4.33/src/helixor_agent/group.py +338 -0
- helixor_agent_sdk-0.4.33/src/helixor_agent/invoke.py +885 -0
- helixor_agent_sdk-0.4.33/src/helixor_agent/local_config.py +10 -0
- helixor_agent_sdk-0.4.33/src/helixor_agent/local_model.py +150 -0
- helixor_agent_sdk-0.4.33/src/helixor_agent/rules.py +251 -0
- helixor_agent_sdk-0.4.33/src/helixor_agent/service_client/__init__.py +7 -0
- helixor_agent_sdk-0.4.33/src/helixor_agent/service_client/_operations/__init__.py +29 -0
- helixor_agent_sdk-0.4.33/src/helixor_agent/service_client/_operations/agent_operations.py +59 -0
- helixor_agent_sdk-0.4.33/src/helixor_agent/service_client/_operations/management_operations_part_1.py +304 -0
- helixor_agent_sdk-0.4.33/src/helixor_agent/service_client/_operations/management_operations_part_2.py +248 -0
- helixor_agent_sdk-0.4.33/src/helixor_agent/service_client/_operations/owner_operations.py +73 -0
- helixor_agent_sdk-0.4.33/src/helixor_agent/service_client/client.py +223 -0
- helixor_agent_sdk-0.4.33/src/helixor_agent/service_client/contract.py +97 -0
- helixor_agent_sdk-0.4.33/src/helixor_agent/service_client/errors.py +46 -0
- helixor_agent_sdk-0.4.33/src/helixor_agent/service_client/models.py +2328 -0
- helixor_agent_sdk-0.4.33/src/helixor_agent/service_client/operation_contracts.py +79 -0
- helixor_agent_sdk-0.4.33/src/helixor_agent/starters.py +100 -0
- helixor_agent_sdk-0.4.33/src/helixor_agent/templates/expense-checker/agent.yaml +27 -0
- helixor_agent_sdk-0.4.33/src/helixor_agent/templates/expense-checker/skills/check-expense/SKILL.md +9 -0
- helixor_agent_sdk-0.4.33/src/helixor_agent/templates/expense-checker/skills/file-expense/SKILL.md +6 -0
- helixor_agent_sdk-0.4.33/src/helixor_agent/templates/expense-checker/tools.py +49 -0
- helixor_agent_sdk-0.4.33/src/helixor_agent/templates/meeting-scheduler/agent.yaml +27 -0
- helixor_agent_sdk-0.4.33/src/helixor_agent/templates/meeting-scheduler/skills/book/SKILL.md +5 -0
- helixor_agent_sdk-0.4.33/src/helixor_agent/templates/meeting-scheduler/skills/find-time/SKILL.md +5 -0
- helixor_agent_sdk-0.4.33/src/helixor_agent/templates/meeting-scheduler/tools.py +60 -0
- helixor_agent_sdk-0.4.33/src/helixor_agent/templates/refund-helper/agent.yaml +28 -0
- helixor_agent_sdk-0.4.33/src/helixor_agent/templates/refund-helper/skills/order-status/SKILL.md +6 -0
- helixor_agent_sdk-0.4.33/src/helixor_agent/templates/refund-helper/skills/refunds/SKILL.md +9 -0
- helixor_agent_sdk-0.4.33/src/helixor_agent/templates/refund-helper/tools.py +54 -0
- helixor_agent_sdk-0.4.33/src/helixor_agent/templates/research-assistant/agent.yaml +28 -0
- helixor_agent_sdk-0.4.33/src/helixor_agent/templates/research-assistant/skills/research/SKILL.md +6 -0
- helixor_agent_sdk-0.4.33/src/helixor_agent/templates/research-assistant/skills/write-brief/SKILL.md +6 -0
- helixor_agent_sdk-0.4.33/src/helixor_agent/templates/research-assistant/tools.py +37 -0
- helixor_agent_sdk-0.4.33/src/helixor_agent/templates/support-assistant/agent.yaml +28 -0
- helixor_agent_sdk-0.4.33/src/helixor_agent/templates/support-assistant/skills/answer-from-docs/SKILL.md +7 -0
- helixor_agent_sdk-0.4.33/src/helixor_agent/templates/support-assistant/skills/open-ticket/SKILL.md +7 -0
- helixor_agent_sdk-0.4.33/src/helixor_agent/templates/support-assistant/tools.py +40 -0
- helixor_agent_sdk-0.4.33/src/helixor_agent/tool.py +100 -0
- helixor_agent_sdk-0.4.33/src/helixor_agent/tool_state.py +57 -0
- helixor_agent_sdk-0.4.33/src/helixor_agent/work_lists.py +295 -0
- helixor_agent_sdk-0.4.33/src/helixor_agent/yaml_text.py +31 -0
- helixor_agent_sdk-0.4.33/src/helixor_agent_sdk.egg-info/PKG-INFO +25 -0
- helixor_agent_sdk-0.4.33/src/helixor_agent_sdk.egg-info/SOURCES.txt +64 -0
- helixor_agent_sdk-0.4.33/src/helixor_agent_sdk.egg-info/dependency_links.txt +1 -0
- helixor_agent_sdk-0.4.33/src/helixor_agent_sdk.egg-info/requires.txt +3 -0
- helixor_agent_sdk-0.4.33/src/helixor_agent_sdk.egg-info/top_level.txt +2 -0
- helixor_agent_sdk-0.4.33/src/helixor_exec_pool_client/__init__.py +7 -0
- helixor_agent_sdk-0.4.33/src/helixor_exec_pool_client/_operations/__init__.py +16 -0
- helixor_agent_sdk-0.4.33/src/helixor_exec_pool_client/_operations/pool_operations.py +64 -0
- helixor_agent_sdk-0.4.33/src/helixor_exec_pool_client/client.py +221 -0
- helixor_agent_sdk-0.4.33/src/helixor_exec_pool_client/contract.py +19 -0
- helixor_agent_sdk-0.4.33/src/helixor_exec_pool_client/errors.py +46 -0
- helixor_agent_sdk-0.4.33/src/helixor_exec_pool_client/models.py +223 -0
- 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,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,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]
|