hx-cli 0.1.0__py3-none-any.whl
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.
- hx/__init__.py +5 -0
- hx/agents/__init__.py +1 -0
- hx/agents/definitions.py +106 -0
- hx/agents/subagent.py +190 -0
- hx/cli.py +667 -0
- hx/config.py +277 -0
- hx/core/__init__.py +1 -0
- hx/core/compaction.py +245 -0
- hx/core/context.py +271 -0
- hx/core/events.py +183 -0
- hx/core/lateinject.py +121 -0
- hx/core/loop.py +537 -0
- hx/core/messages.py +164 -0
- hx/core/session.py +208 -0
- hx/core/usage.py +129 -0
- hx/frontmatter.py +80 -0
- hx/mcp/__init__.py +1 -0
- hx/mcp/client.py +319 -0
- hx/mcp/manager.py +265 -0
- hx/paths.py +90 -0
- hx/permissions/__init__.py +7 -0
- hx/permissions/engine.py +406 -0
- hx/permissions/parser.py +306 -0
- hx/permissions/sandbox.py +227 -0
- hx/providers/__init__.py +1 -0
- hx/providers/base.py +77 -0
- hx/providers/fake.py +87 -0
- hx/providers/models.py +238 -0
- hx/providers/openrouter.py +468 -0
- hx/skills/__init__.py +1 -0
- hx/skills/loader.py +102 -0
- hx/skills/runtime.py +84 -0
- hx/tools/__init__.py +1 -0
- hx/tools/base.py +97 -0
- hx/tools/bash.py +544 -0
- hx/tools/edit.py +167 -0
- hx/tools/glob.py +75 -0
- hx/tools/grep.py +165 -0
- hx/tools/output.py +133 -0
- hx/tools/read.py +142 -0
- hx/tools/registry.py +149 -0
- hx/tools/task.py +76 -0
- hx/tools/todo.py +149 -0
- hx/tools/write.py +87 -0
- hx/tui/__init__.py +1 -0
- hx/tui/app.py +487 -0
- hx/tui/commands.py +399 -0
- hx/tui/hx.tcss +197 -0
- hx/tui/renderers.py +570 -0
- hx/tui/theme.py +322 -0
- hx/tui/widgets/__init__.py +1 -0
- hx/tui/widgets/configure.py +95 -0
- hx/tui/widgets/diff.py +25 -0
- hx/tui/widgets/input.py +145 -0
- hx/tui/widgets/palette.py +130 -0
- hx/tui/widgets/permission.py +97 -0
- hx/tui/widgets/statusbar.py +212 -0
- hx/tui/widgets/todos.py +116 -0
- hx/tui/widgets/transcript.py +316 -0
- hx/tui/widgets/working.py +78 -0
- hx_cli-0.1.0.dist-info/METADATA +430 -0
- hx_cli-0.1.0.dist-info/RECORD +64 -0
- hx_cli-0.1.0.dist-info/WHEEL +4 -0
- hx_cli-0.1.0.dist-info/entry_points.txt +2 -0
hx/__init__.py
ADDED
hx/agents/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Subagents: isolated agent loops spawned by the Task tool."""
|
hx/agents/definitions.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Agent definitions from ``.hx/agents/*.md`` and ``~/.hx/agents/*.md``.
|
|
2
|
+
|
|
3
|
+
Same frontmatter shape as skills, plus ``tools`` and ``model``.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from hx.frontmatter import FrontmatterError, read, require, string_tuple
|
|
13
|
+
from hx.paths import project_agents_dir, user_agents_dir
|
|
14
|
+
|
|
15
|
+
log = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(slots=True)
|
|
19
|
+
class AgentDefinition:
|
|
20
|
+
name: str
|
|
21
|
+
description: str
|
|
22
|
+
"""Shown to the parent model in the Task tool schema, so it picks the right agent."""
|
|
23
|
+
system_prompt: str
|
|
24
|
+
tools: tuple[str, ...] = ()
|
|
25
|
+
"""Allowlist. Empty means all tools except Task."""
|
|
26
|
+
model: str | None = None
|
|
27
|
+
"""Defaults to ``settings.models.subagent_model``."""
|
|
28
|
+
path: Path | None = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
_EXPLORE = AgentDefinition(
|
|
32
|
+
name="explore",
|
|
33
|
+
description=(
|
|
34
|
+
"Read-only search across the codebase. Use when finding where something lives "
|
|
35
|
+
"would cost the main conversation many tool results."
|
|
36
|
+
),
|
|
37
|
+
system_prompt=(
|
|
38
|
+
"You are a search agent. Locate the relevant code and report back concisely.\n\n"
|
|
39
|
+
"Read only what you need - excerpts, not whole files. Report file paths with "
|
|
40
|
+
"line numbers and a one-line note on what each contains. Do not review or "
|
|
41
|
+
"critique the code, and do not modify anything.\n\n"
|
|
42
|
+
"Your reply is the entire result: the caller cannot see your tool output."
|
|
43
|
+
),
|
|
44
|
+
tools=("Read", "Glob", "Grep"),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
_PLAN = AgentDefinition(
|
|
48
|
+
name="plan",
|
|
49
|
+
description="Design an implementation approach for a task, without writing code.",
|
|
50
|
+
system_prompt=(
|
|
51
|
+
"You are a software architect. Produce a concrete implementation plan.\n\n"
|
|
52
|
+
"Read the relevant code first. Name the files to change and what changes in "
|
|
53
|
+
"each, reuse what already exists rather than inventing parallel machinery, "
|
|
54
|
+
"and state the trade-offs you rejected. Do not modify anything.\n\n"
|
|
55
|
+
"Your reply is the entire result: the caller cannot see your tool output."
|
|
56
|
+
),
|
|
57
|
+
tools=("Read", "Glob", "Grep"),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
_GENERAL = AgentDefinition(
|
|
61
|
+
name="general",
|
|
62
|
+
description="A multi-step task that does not fit the other agents.",
|
|
63
|
+
system_prompt=(
|
|
64
|
+
"Carry out the task described and report what you did.\n\n"
|
|
65
|
+
"You cannot ask follow-up questions, so make reasonable decisions and state "
|
|
66
|
+
"the assumptions you made. Your reply is the entire result: the caller "
|
|
67
|
+
"cannot see your tool output."
|
|
68
|
+
),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
BUILTIN_AGENTS: tuple[AgentDefinition, ...] = (_EXPLORE, _PLAN, _GENERAL)
|
|
72
|
+
"""Always available: read-only search, planning, and a catch-all."""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def discover(cwd: Path) -> list[AgentDefinition]:
|
|
76
|
+
"""Builtins plus user and project definitions, project last."""
|
|
77
|
+
found: dict[str, AgentDefinition] = {agent.name: agent for agent in BUILTIN_AGENTS}
|
|
78
|
+
|
|
79
|
+
for directory in (user_agents_dir(), project_agents_dir(cwd)):
|
|
80
|
+
if not directory.is_dir():
|
|
81
|
+
continue
|
|
82
|
+
for path in sorted(directory.glob("*.md")):
|
|
83
|
+
try:
|
|
84
|
+
agent = parse_agent_file(path)
|
|
85
|
+
except FrontmatterError as exc:
|
|
86
|
+
log.warning("skipping agent: %s", exc)
|
|
87
|
+
continue
|
|
88
|
+
found[agent.name] = agent
|
|
89
|
+
|
|
90
|
+
return sorted(found.values(), key=lambda agent: agent.name)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def parse_agent_file(path: Path) -> AgentDefinition:
|
|
94
|
+
document = read(path)
|
|
95
|
+
require(document, "name", "description")
|
|
96
|
+
return AgentDefinition(
|
|
97
|
+
name=str(document.metadata["name"]).strip(),
|
|
98
|
+
description=str(document.metadata["description"]).strip(),
|
|
99
|
+
system_prompt=document.body,
|
|
100
|
+
tools=string_tuple(document.metadata.get("tools")),
|
|
101
|
+
model=(str(document.metadata["model"]).strip() if document.metadata.get("model") else None),
|
|
102
|
+
path=path,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
InvalidAgent = FrontmatterError
|
hx/agents/subagent.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Subagent execution.
|
|
2
|
+
|
|
3
|
+
Each subagent gets its own :class:`~hx.core.loop.AgentLoop`, transcript, tool
|
|
4
|
+
allowlist and model. ``Task`` is never in a subagent's allowlist, so recursion
|
|
5
|
+
is impossible by construction rather than by a depth counter.
|
|
6
|
+
|
|
7
|
+
Only the final assistant text returns to the parent as the tool result - the
|
|
8
|
+
subagent's intermediate tool output never enters the parent's context, which is
|
|
9
|
+
the entire reason to spawn one.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import uuid
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import TYPE_CHECKING
|
|
17
|
+
|
|
18
|
+
from hx.agents.definitions import AgentDefinition
|
|
19
|
+
from hx.core.events import SubagentFinished, SubagentStarted
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from hx.config import Settings
|
|
23
|
+
from hx.core.events import EventBus
|
|
24
|
+
from hx.permissions.engine import PermissionEngine
|
|
25
|
+
from hx.providers.base import Provider
|
|
26
|
+
from hx.providers.models import ModelRegistry
|
|
27
|
+
from hx.tools.registry import ToolRegistry
|
|
28
|
+
|
|
29
|
+
NEVER_AVAILABLE_TO_SUBAGENTS = frozenset({"Task"})
|
|
30
|
+
"""Recursion is prevented by omission, not by a depth counter that someone will
|
|
31
|
+
later raise 'just for this case'."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(slots=True)
|
|
35
|
+
class SubagentResult:
|
|
36
|
+
subagent_id: str
|
|
37
|
+
agent_type: str
|
|
38
|
+
report: str
|
|
39
|
+
is_error: bool
|
|
40
|
+
turns: int
|
|
41
|
+
cost_usd: float
|
|
42
|
+
"""Rolled into the parent session's ledger so the cost display stays honest."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class SubagentRunner:
|
|
46
|
+
"""Spawns and supervises subagents."""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
*,
|
|
51
|
+
definitions: dict[str, AgentDefinition],
|
|
52
|
+
provider: Provider,
|
|
53
|
+
tools: ToolRegistry,
|
|
54
|
+
permissions: PermissionEngine | None,
|
|
55
|
+
bus: EventBus,
|
|
56
|
+
settings: Settings,
|
|
57
|
+
models: ModelRegistry | None = None,
|
|
58
|
+
parent_session_id: str | None = None,
|
|
59
|
+
parent_usage: object | None = None,
|
|
60
|
+
) -> None:
|
|
61
|
+
self.definitions = definitions
|
|
62
|
+
self.provider = provider
|
|
63
|
+
self.tools = tools
|
|
64
|
+
self.permissions = permissions
|
|
65
|
+
self.bus = bus
|
|
66
|
+
self.settings = settings
|
|
67
|
+
self.models = models
|
|
68
|
+
self.parent_session_id = parent_session_id
|
|
69
|
+
self.parent_usage = parent_usage
|
|
70
|
+
self._active: dict[str, str] = {}
|
|
71
|
+
|
|
72
|
+
async def run(self, agent_type: str, prompt: str, description: str) -> SubagentResult:
|
|
73
|
+
"""Run one subagent to completion.
|
|
74
|
+
|
|
75
|
+
Permission prompts from a subagent surface in the parent TUI attributed
|
|
76
|
+
to that subagent - an approval modal with no visible origin is not an
|
|
77
|
+
informed approval.
|
|
78
|
+
"""
|
|
79
|
+
definition = self.definitions.get(agent_type)
|
|
80
|
+
if definition is None:
|
|
81
|
+
available = ", ".join(sorted(self.definitions)) or "none"
|
|
82
|
+
return SubagentResult(
|
|
83
|
+
subagent_id="",
|
|
84
|
+
agent_type=agent_type,
|
|
85
|
+
report=f"Unknown agent type {agent_type!r}. Available: {available}",
|
|
86
|
+
is_error=True,
|
|
87
|
+
turns=0,
|
|
88
|
+
cost_usd=0.0,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
subagent_id = f"sub_{uuid.uuid4().hex[:8]}"
|
|
92
|
+
self._active[subagent_id] = agent_type
|
|
93
|
+
self.bus.publish(
|
|
94
|
+
SubagentStarted(subagent_id=subagent_id, agent_type=agent_type, description=description)
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
loop = self._build_loop(definition, subagent_id)
|
|
99
|
+
result = await loop.run(prompt)
|
|
100
|
+
report = _final_text(result) or "(the subagent returned no text)"
|
|
101
|
+
cost = loop.session.usage.total_cost_usd
|
|
102
|
+
self._roll_up_cost(loop)
|
|
103
|
+
return SubagentResult(
|
|
104
|
+
subagent_id=subagent_id,
|
|
105
|
+
agent_type=agent_type,
|
|
106
|
+
report=report,
|
|
107
|
+
is_error=result.error is not None,
|
|
108
|
+
turns=len(loop.session.usage.turns),
|
|
109
|
+
cost_usd=cost,
|
|
110
|
+
)
|
|
111
|
+
except Exception as exc:
|
|
112
|
+
return SubagentResult(
|
|
113
|
+
subagent_id=subagent_id,
|
|
114
|
+
agent_type=agent_type,
|
|
115
|
+
report=f"The subagent failed: {type(exc).__name__}: {exc}",
|
|
116
|
+
is_error=True,
|
|
117
|
+
turns=0,
|
|
118
|
+
cost_usd=0.0,
|
|
119
|
+
)
|
|
120
|
+
finally:
|
|
121
|
+
self._active.pop(subagent_id, None)
|
|
122
|
+
self.bus.publish(SubagentFinished(subagent_id=subagent_id, is_error=False))
|
|
123
|
+
|
|
124
|
+
def _build_loop(self, definition: AgentDefinition, subagent_id: str): # type: ignore[no-untyped-def]
|
|
125
|
+
from hx.core.compaction import Compactor
|
|
126
|
+
from hx.core.context import ContextBuilder
|
|
127
|
+
from hx.core.lateinject import InjectionRegistry
|
|
128
|
+
from hx.core.loop import AgentLoop
|
|
129
|
+
from hx.core.session import new_session
|
|
130
|
+
|
|
131
|
+
model = (
|
|
132
|
+
definition.model or self.settings.models.subagent_model or self.settings.models.model
|
|
133
|
+
)
|
|
134
|
+
tools = self.tools.subset(self._allowed_tools(definition))
|
|
135
|
+
context = ContextBuilder(
|
|
136
|
+
definition.system_prompt,
|
|
137
|
+
self.settings.cwd,
|
|
138
|
+
keep_recent_turns=self.settings.context.keep_recent_turns,
|
|
139
|
+
)
|
|
140
|
+
session = new_session(self.settings.cwd, model, parent_id=self.parent_session_id)
|
|
141
|
+
|
|
142
|
+
loop = AgentLoop(
|
|
143
|
+
provider=self.provider,
|
|
144
|
+
session=session,
|
|
145
|
+
tools=tools,
|
|
146
|
+
permissions=self.permissions,
|
|
147
|
+
context=context,
|
|
148
|
+
compactor=Compactor(
|
|
149
|
+
provider=self.provider,
|
|
150
|
+
model=model,
|
|
151
|
+
keep_recent_turns=self.settings.context.keep_recent_turns,
|
|
152
|
+
context=context,
|
|
153
|
+
),
|
|
154
|
+
injections=InjectionRegistry(),
|
|
155
|
+
bus=self.bus,
|
|
156
|
+
settings=self.settings,
|
|
157
|
+
model_info=self.models.get_or_default(model) if self.models else None,
|
|
158
|
+
)
|
|
159
|
+
loop.origin = f"{definition.name} subagent"
|
|
160
|
+
return loop
|
|
161
|
+
|
|
162
|
+
def _allowed_tools(self, definition: AgentDefinition) -> set[str]:
|
|
163
|
+
available = set(self.tools.names()) - set(NEVER_AVAILABLE_TO_SUBAGENTS)
|
|
164
|
+
if not definition.tools:
|
|
165
|
+
return available
|
|
166
|
+
return {name for name in definition.tools if name in available}
|
|
167
|
+
|
|
168
|
+
def _roll_up_cost(self, loop: object) -> None:
|
|
169
|
+
"""Fold the subagent's usage into the parent ledger.
|
|
170
|
+
|
|
171
|
+
A subagent that spends real money without moving the parent's cost
|
|
172
|
+
display makes that display a lie.
|
|
173
|
+
"""
|
|
174
|
+
parent = self.parent_usage
|
|
175
|
+
if parent is None:
|
|
176
|
+
return
|
|
177
|
+
for turn in loop.session.usage.turns: # type: ignore[attr-defined]
|
|
178
|
+
parent.record(turn) # type: ignore[attr-defined]
|
|
179
|
+
|
|
180
|
+
def active(self) -> list[str]:
|
|
181
|
+
"""Ids of running subagents, for the TUI progress rows."""
|
|
182
|
+
return sorted(self._active)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _final_text(result: object) -> str:
|
|
186
|
+
messages = getattr(result, "messages", [])
|
|
187
|
+
for message in reversed(messages):
|
|
188
|
+
if message.role == "assistant" and message.text().strip():
|
|
189
|
+
return str(message.text().strip())
|
|
190
|
+
return ""
|