noah-code 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.
- noah_code/__init__.py +3 -0
- noah_code/__main__.py +6 -0
- noah_code/agent.py +378 -0
- noah_code/approvals.py +105 -0
- noah_code/cli.py +422 -0
- noah_code/commands.py +70 -0
- noah_code/config.py +279 -0
- noah_code/custom_commands.py +103 -0
- noah_code/event_bridge.py +132 -0
- noah_code/events.py +27 -0
- noah_code/host.py +662 -0
- noah_code/macos_sandbox.py +142 -0
- noah_code/mcp_setup.py +91 -0
- noah_code/permissions.py +400 -0
- noah_code/sessions.py +157 -0
- noah_code/skills_setup.py +51 -0
- noah_code/snapshots.py +313 -0
- noah_code/tools/__init__.py +6 -0
- noah_code/tools/git_tools.py +44 -0
- noah_code/tools/workspace_tools.py +269 -0
- noah_code/ui/__init__.py +6 -0
- noah_code/ui/console.py +88 -0
- noah_code/ui/protocol.py +33 -0
- noah_code/ui/textual.css +9 -0
- noah_code/ui/textual_app.py +435 -0
- noah_code/updates.py +184 -0
- noah_code/workspace.py +49 -0
- noah_code-0.1.0.dist-info/METADATA +173 -0
- noah_code-0.1.0.dist-info/RECORD +31 -0
- noah_code-0.1.0.dist-info/WHEEL +4 -0
- noah_code-0.1.0.dist-info/entry_points.txt +4 -0
noah_code/config.py
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""Layered configuration for Noah Code."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Literal
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, Field, field_validator
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
import tomllib
|
|
13
|
+
except ModuleNotFoundError: # pragma: no cover
|
|
14
|
+
import tomli as tomllib # type: ignore[no-redef]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
PermissionAction = Literal["allow", "ask", "deny"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class PermissionRule(BaseModel):
|
|
21
|
+
"""Ordered permission rule; last match wins."""
|
|
22
|
+
|
|
23
|
+
category: str = "*"
|
|
24
|
+
pattern: str = "*"
|
|
25
|
+
action: PermissionAction = "ask"
|
|
26
|
+
reason: str = ""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class TracingConfig(BaseModel):
|
|
30
|
+
enabled: bool = True
|
|
31
|
+
jsonl_dir: str | None = None
|
|
32
|
+
viewer: bool = True
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class SummarizationPolicy(BaseModel):
|
|
36
|
+
policy: Literal["token_budget", "none"] = "token_budget"
|
|
37
|
+
max_tokens: int | None = None
|
|
38
|
+
preserve_recent: int = 10
|
|
39
|
+
target_chars: int = 4000
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class UIConfig(BaseModel):
|
|
43
|
+
show_reasoning: bool = False
|
|
44
|
+
markdown: bool = True
|
|
45
|
+
stream_shell: bool = True
|
|
46
|
+
frontend: Literal["tui", "console"] = "tui"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class UpdateConfig(BaseModel):
|
|
50
|
+
auto_install: bool = True
|
|
51
|
+
interval_hours: int = Field(default=24, ge=1, le=24 * 30)
|
|
52
|
+
check_timeout_seconds: float = Field(default=3.0, gt=0, le=30)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class NoahCodeConfig(BaseModel):
|
|
56
|
+
"""Resolved configuration for a noah-code run."""
|
|
57
|
+
|
|
58
|
+
model: str = "gpt-4o-mini"
|
|
59
|
+
lightweight_model: str | None = None
|
|
60
|
+
max_iterations: int = 40
|
|
61
|
+
cell_timeout: float = 120.0
|
|
62
|
+
command_timeout: float = 60.0
|
|
63
|
+
summarization: SummarizationPolicy = Field(default_factory=SummarizationPolicy)
|
|
64
|
+
tracing: TracingConfig = Field(default_factory=TracingConfig)
|
|
65
|
+
session_dir: Path = Field(
|
|
66
|
+
default_factory=lambda: Path.home() / ".local" / "share" / "noah-code" / "sessions"
|
|
67
|
+
)
|
|
68
|
+
permission_rules: list[PermissionRule] = Field(default_factory=list)
|
|
69
|
+
auto_approve: bool = False
|
|
70
|
+
enabled_skills: list[str] = Field(default_factory=list)
|
|
71
|
+
mcp: dict[str, Any] = Field(default_factory=dict)
|
|
72
|
+
ui: UIConfig = Field(default_factory=UIConfig)
|
|
73
|
+
updates: UpdateConfig = Field(default_factory=UpdateConfig)
|
|
74
|
+
mode: Literal["build", "plan"] = "build"
|
|
75
|
+
max_file_bytes: int = 512_000
|
|
76
|
+
max_output_chars: int = 80_000
|
|
77
|
+
undo_blob_limit: int = 2_000_000
|
|
78
|
+
unsafe_inprocess_code_execution: bool = False
|
|
79
|
+
|
|
80
|
+
@field_validator("session_dir", mode="before")
|
|
81
|
+
@classmethod
|
|
82
|
+
def _coerce_path(cls, value: Any) -> Path:
|
|
83
|
+
return (
|
|
84
|
+
Path(value).expanduser()
|
|
85
|
+
if value is not None
|
|
86
|
+
else Path.home() / ".local/share/noah-code/sessions"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
DEFAULT_PERMISSION_RULES: list[PermissionRule] = [
|
|
91
|
+
PermissionRule(category="read", pattern="*", action="allow", reason="reads allowed"),
|
|
92
|
+
PermissionRule(
|
|
93
|
+
category="read",
|
|
94
|
+
pattern="**/.env",
|
|
95
|
+
action="deny",
|
|
96
|
+
reason="secret env files denied",
|
|
97
|
+
),
|
|
98
|
+
PermissionRule(
|
|
99
|
+
category="read",
|
|
100
|
+
pattern="**/.env.*",
|
|
101
|
+
action="deny",
|
|
102
|
+
reason="secret env files denied",
|
|
103
|
+
),
|
|
104
|
+
PermissionRule(
|
|
105
|
+
category="read",
|
|
106
|
+
pattern="**/.env.example",
|
|
107
|
+
action="allow",
|
|
108
|
+
reason="example env files are safe",
|
|
109
|
+
),
|
|
110
|
+
PermissionRule(
|
|
111
|
+
category="read",
|
|
112
|
+
pattern="**/*.pem",
|
|
113
|
+
action="deny",
|
|
114
|
+
reason="private keys denied",
|
|
115
|
+
),
|
|
116
|
+
PermissionRule(
|
|
117
|
+
category="read",
|
|
118
|
+
pattern="**/*id_rsa*",
|
|
119
|
+
action="deny",
|
|
120
|
+
reason="private keys denied",
|
|
121
|
+
),
|
|
122
|
+
PermissionRule(
|
|
123
|
+
category="read",
|
|
124
|
+
pattern="**/.git/**",
|
|
125
|
+
action="deny",
|
|
126
|
+
reason=".git internals denied",
|
|
127
|
+
),
|
|
128
|
+
PermissionRule(
|
|
129
|
+
category="read",
|
|
130
|
+
pattern="**/noah-code/**/*.db",
|
|
131
|
+
action="deny",
|
|
132
|
+
reason="session databases denied",
|
|
133
|
+
),
|
|
134
|
+
PermissionRule(category="edit", pattern="*", action="ask", reason="edits require approval"),
|
|
135
|
+
PermissionRule(category="bash", pattern="*", action="ask", reason="shell requires approval"),
|
|
136
|
+
PermissionRule(
|
|
137
|
+
category="bash",
|
|
138
|
+
pattern="git push*",
|
|
139
|
+
action="deny",
|
|
140
|
+
reason="push denied by default",
|
|
141
|
+
),
|
|
142
|
+
PermissionRule(
|
|
143
|
+
category="bash",
|
|
144
|
+
pattern="git clean*",
|
|
145
|
+
action="deny",
|
|
146
|
+
reason="destructive git clean denied",
|
|
147
|
+
),
|
|
148
|
+
PermissionRule(
|
|
149
|
+
category="bash",
|
|
150
|
+
pattern="git reset --hard*",
|
|
151
|
+
action="deny",
|
|
152
|
+
reason="destructive git reset denied",
|
|
153
|
+
),
|
|
154
|
+
PermissionRule(
|
|
155
|
+
category="external_directory",
|
|
156
|
+
pattern="*",
|
|
157
|
+
action="ask",
|
|
158
|
+
reason="external paths require approval",
|
|
159
|
+
),
|
|
160
|
+
PermissionRule(category="task", pattern="*", action="ask", reason="subagents require approval"),
|
|
161
|
+
PermissionRule(category="skill", pattern="*", action="ask", reason="skills require approval"),
|
|
162
|
+
PermissionRule(category="mcp", pattern="*", action="ask", reason="MCP requires approval"),
|
|
163
|
+
PermissionRule(category="lsp", pattern="*", action="allow", reason="LSP read-only by default"),
|
|
164
|
+
]
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _user_config_path() -> Path:
|
|
168
|
+
return Path.home() / ".config" / "noah-code" / "config.toml"
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _project_config_path(workspace: Path) -> Path:
|
|
172
|
+
return workspace / ".noah-code" / "config.toml"
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# Repository-controlled configuration must not be able to weaken the host's
|
|
176
|
+
# trust boundary. These settings are accepted only from user config, the
|
|
177
|
+
# environment, or explicit CLI overrides.
|
|
178
|
+
_USER_ONLY_CONFIG_KEYS = frozenset(
|
|
179
|
+
{
|
|
180
|
+
"auto_approve",
|
|
181
|
+
"enabled_skills",
|
|
182
|
+
"mcp",
|
|
183
|
+
"permission_rules",
|
|
184
|
+
"session_dir",
|
|
185
|
+
"tracing",
|
|
186
|
+
"unsafe_inprocess_code_execution",
|
|
187
|
+
"updates",
|
|
188
|
+
}
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _load_toml(path: Path) -> dict[str, Any]:
|
|
193
|
+
if not path.is_file():
|
|
194
|
+
return {}
|
|
195
|
+
with path.open("rb") as fh:
|
|
196
|
+
data = tomllib.load(fh)
|
|
197
|
+
return data if isinstance(data, dict) else {}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _load_project_toml(path: Path) -> dict[str, Any]:
|
|
201
|
+
data = _load_toml(path)
|
|
202
|
+
return {key: value for key, value in data.items() if key not in _USER_ONLY_CONFIG_KEYS}
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
|
|
206
|
+
out = dict(base)
|
|
207
|
+
for key, value in override.items():
|
|
208
|
+
if key in out and isinstance(out[key], dict) and isinstance(value, dict):
|
|
209
|
+
out[key] = _deep_merge(out[key], value)
|
|
210
|
+
else:
|
|
211
|
+
out[key] = value
|
|
212
|
+
return out
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _env_overrides() -> dict[str, Any]:
|
|
216
|
+
out: dict[str, Any] = {}
|
|
217
|
+
if model := os.environ.get("NOAH_CODE_MODEL"):
|
|
218
|
+
out["model"] = model
|
|
219
|
+
if light := os.environ.get("NOAH_CODE_LIGHTWEIGHT_MODEL"):
|
|
220
|
+
out["lightweight_model"] = light
|
|
221
|
+
if auto := os.environ.get("NOAH_CODE_AUTO"):
|
|
222
|
+
out["auto_approve"] = auto.lower() in {"1", "true", "yes", "on"}
|
|
223
|
+
if session_dir := os.environ.get("NOAH_CODE_SESSION_DIR"):
|
|
224
|
+
out["session_dir"] = session_dir
|
|
225
|
+
if mode := os.environ.get("NOAH_CODE_MODE"):
|
|
226
|
+
out["mode"] = mode
|
|
227
|
+
if unsafe := os.environ.get("NOAH_CODE_UNSAFE_INPROCESS"):
|
|
228
|
+
out["unsafe_inprocess_code_execution"] = unsafe.lower() in {"1", "true", "yes", "on"}
|
|
229
|
+
if auto_update := os.environ.get("NOAH_CODE_AUTO_UPDATE"):
|
|
230
|
+
out["updates"] = {
|
|
231
|
+
"auto_install": auto_update.lower() in {"1", "true", "yes", "on"}
|
|
232
|
+
}
|
|
233
|
+
return out
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _normalize_raw(raw: dict[str, Any]) -> dict[str, Any]:
|
|
237
|
+
data = dict(raw)
|
|
238
|
+
if "permission_rules" in data and isinstance(data["permission_rules"], list):
|
|
239
|
+
data["permission_rules"] = [
|
|
240
|
+
rule if isinstance(rule, PermissionRule) else PermissionRule.model_validate(rule)
|
|
241
|
+
for rule in data["permission_rules"]
|
|
242
|
+
]
|
|
243
|
+
if "summarization" in data and isinstance(data["summarization"], dict):
|
|
244
|
+
data["summarization"] = SummarizationPolicy.model_validate(data["summarization"])
|
|
245
|
+
if "tracing" in data and isinstance(data["tracing"], dict):
|
|
246
|
+
data["tracing"] = TracingConfig.model_validate(data["tracing"])
|
|
247
|
+
if "ui" in data and isinstance(data["ui"], dict):
|
|
248
|
+
data["ui"] = UIConfig.model_validate(data["ui"])
|
|
249
|
+
if "updates" in data and isinstance(data["updates"], dict):
|
|
250
|
+
data["updates"] = UpdateConfig.model_validate(data["updates"])
|
|
251
|
+
return data
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def load_config(
|
|
255
|
+
workspace: Path,
|
|
256
|
+
*,
|
|
257
|
+
cli_overrides: dict[str, Any] | None = None,
|
|
258
|
+
) -> NoahCodeConfig:
|
|
259
|
+
"""Load config with precedence: defaults < user < project < env < CLI."""
|
|
260
|
+
merged: dict[str, Any] = {
|
|
261
|
+
"permission_rules": [r.model_dump() for r in DEFAULT_PERMISSION_RULES],
|
|
262
|
+
}
|
|
263
|
+
merged = _deep_merge(merged, _load_toml(_user_config_path()))
|
|
264
|
+
merged = _deep_merge(merged, _load_project_toml(_project_config_path(workspace)))
|
|
265
|
+
merged = _deep_merge(merged, _env_overrides())
|
|
266
|
+
if cli_overrides:
|
|
267
|
+
cleaned = {k: v for k, v in cli_overrides.items() if v is not None}
|
|
268
|
+
merged = _deep_merge(merged, cleaned)
|
|
269
|
+
return NoahCodeConfig.model_validate(_normalize_raw(merged))
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def config_sources(workspace: Path) -> dict[str, Path | None]:
|
|
273
|
+
"""Return config file locations for diagnostics."""
|
|
274
|
+
user = _user_config_path()
|
|
275
|
+
project = _project_config_path(workspace)
|
|
276
|
+
return {
|
|
277
|
+
"user": user if user.is_file() else None,
|
|
278
|
+
"project": project if project.is_file() else None,
|
|
279
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""User and project markdown slash commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
import tomllib
|
|
12
|
+
except ModuleNotFoundError: # pragma: no cover
|
|
13
|
+
import tomli as tomllib # type: ignore[no-redef]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
_FRONTMATTER = re.compile(r"\A---\s*\n(.*?)\n---\s*\n(.*)\Z", re.DOTALL)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class CustomCommand:
|
|
21
|
+
name: str
|
|
22
|
+
description: str
|
|
23
|
+
body: str
|
|
24
|
+
mode: str | None = None
|
|
25
|
+
model: str | None = None
|
|
26
|
+
source: str = ""
|
|
27
|
+
|
|
28
|
+
def render(self, arguments: str) -> str:
|
|
29
|
+
"""Expand $ARGUMENTS and $1..$9 positional placeholders."""
|
|
30
|
+
parts = _split_args(arguments)
|
|
31
|
+
text = self.body
|
|
32
|
+
text = text.replace("$ARGUMENTS", arguments.strip())
|
|
33
|
+
for i, part in enumerate(parts, start=1):
|
|
34
|
+
text = text.replace(f"${i}", part)
|
|
35
|
+
# Clear unused numbered placeholders.
|
|
36
|
+
text = re.sub(r"\$[1-9]\b", "", text)
|
|
37
|
+
return text.strip()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _split_args(arguments: str) -> list[str]:
|
|
41
|
+
import shlex
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
return shlex.split(arguments)
|
|
45
|
+
except ValueError:
|
|
46
|
+
return arguments.split()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _parse_frontmatter(raw: str) -> tuple[dict[str, Any], str]:
|
|
50
|
+
match = _FRONTMATTER.match(raw)
|
|
51
|
+
if not match:
|
|
52
|
+
return {}, raw
|
|
53
|
+
meta_raw, body = match.group(1), match.group(2)
|
|
54
|
+
meta: dict[str, Any] = {}
|
|
55
|
+
# Prefer YAML-like simple key: value lines; fall back to TOML table.
|
|
56
|
+
for line in meta_raw.splitlines():
|
|
57
|
+
line = line.strip()
|
|
58
|
+
if not line or line.startswith("#"):
|
|
59
|
+
continue
|
|
60
|
+
if ":" in line:
|
|
61
|
+
key, _, val = line.partition(":")
|
|
62
|
+
meta[key.strip()] = val.strip().strip("\"'")
|
|
63
|
+
if not meta:
|
|
64
|
+
try:
|
|
65
|
+
parsed = tomllib.loads(meta_raw)
|
|
66
|
+
if isinstance(parsed, dict):
|
|
67
|
+
meta = parsed
|
|
68
|
+
except Exception: # noqa: BLE001
|
|
69
|
+
meta = {}
|
|
70
|
+
return meta, body
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def load_commands_from_dir(directory: Path, *, source: str) -> dict[str, CustomCommand]:
|
|
74
|
+
out: dict[str, CustomCommand] = {}
|
|
75
|
+
if not directory.is_dir():
|
|
76
|
+
return out
|
|
77
|
+
for path in sorted(directory.glob("*.md")):
|
|
78
|
+
name = path.stem.strip().lower().lstrip("/")
|
|
79
|
+
if not name or name.startswith("."):
|
|
80
|
+
continue
|
|
81
|
+
try:
|
|
82
|
+
raw = path.read_text(encoding="utf-8")
|
|
83
|
+
except OSError:
|
|
84
|
+
continue
|
|
85
|
+
meta, body = _parse_frontmatter(raw)
|
|
86
|
+
out[name] = CustomCommand(
|
|
87
|
+
name=name,
|
|
88
|
+
description=str(meta.get("description") or name),
|
|
89
|
+
body=body,
|
|
90
|
+
mode=(str(meta["mode"]).lower() if meta.get("mode") else None),
|
|
91
|
+
model=str(meta["model"]) if meta.get("model") else None,
|
|
92
|
+
source=f"{source}:{path.name}",
|
|
93
|
+
)
|
|
94
|
+
return out
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def discover_custom_commands(workspace: Path) -> dict[str, CustomCommand]:
|
|
98
|
+
"""Project commands override user commands with the same name."""
|
|
99
|
+
user_dir = Path.home() / ".config" / "noah-code" / "commands"
|
|
100
|
+
project_dir = workspace / ".noah-code" / "commands"
|
|
101
|
+
commands = load_commands_from_dir(user_dir, source="user")
|
|
102
|
+
commands.update(load_commands_from_dir(project_dir, source="project"))
|
|
103
|
+
return commands
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Bridge NOOA EventManager events into HostEvent for UI streaming."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from noah_code.events import HostEvent, HostEventKind
|
|
9
|
+
|
|
10
|
+
Unsubscribe = Callable[[], None]
|
|
11
|
+
EmitFn = Callable[[HostEvent], None]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def install_event_bridge(agent: Any, emit: EmitFn) -> list[Unsubscribe]:
|
|
15
|
+
"""Subscribe to agent.event_manager and forward useful events to the UI.
|
|
16
|
+
|
|
17
|
+
Returns unsubscribe callables (call all on host close / session switch).
|
|
18
|
+
"""
|
|
19
|
+
em = agent.event_manager
|
|
20
|
+
unsubs: list[Unsubscribe] = []
|
|
21
|
+
|
|
22
|
+
def on_tool_call(event: Any) -> None:
|
|
23
|
+
# Fresh ToolCallEvent has result=None; updates do not re-emit.
|
|
24
|
+
if getattr(event, "result", None) is not None:
|
|
25
|
+
return
|
|
26
|
+
name = getattr(event, "name", "tool")
|
|
27
|
+
args = getattr(event, "arguments", {}) or {}
|
|
28
|
+
preview = ""
|
|
29
|
+
if name == "execute_python":
|
|
30
|
+
code = str(args.get("code", ""))
|
|
31
|
+
preview = code.strip().splitlines()[0][:80] if code.strip() else ""
|
|
32
|
+
text = f"execute_python{(': ' + preview) if preview else ''}"
|
|
33
|
+
else:
|
|
34
|
+
text = f"{name}({_brief_args(args)})"
|
|
35
|
+
emit(HostEvent(HostEventKind.TOOL_START, text, meta={"tool": name}))
|
|
36
|
+
|
|
37
|
+
def on_python_output(event: Any) -> None:
|
|
38
|
+
status = str(getattr(event, "execution_status", "") or "")
|
|
39
|
+
err = (getattr(event, "error", "") or "").strip()
|
|
40
|
+
stdout = (getattr(event, "stdout", "") or "").strip()
|
|
41
|
+
stderr = (getattr(event, "stderr", "") or "").strip()
|
|
42
|
+
parts = [f"code cell {status}".strip()]
|
|
43
|
+
if err:
|
|
44
|
+
parts.append(err[:200])
|
|
45
|
+
elif stderr:
|
|
46
|
+
parts.append(stderr[:200])
|
|
47
|
+
elif stdout:
|
|
48
|
+
line = stdout.splitlines()[0][:80]
|
|
49
|
+
parts.append(line)
|
|
50
|
+
emit(
|
|
51
|
+
HostEvent(
|
|
52
|
+
HostEventKind.TOOL_FINISH,
|
|
53
|
+
" · ".join(p for p in parts if p),
|
|
54
|
+
meta={"kind": "python_output"},
|
|
55
|
+
)
|
|
56
|
+
)
|
|
57
|
+
# Stream truncated stdout/stderr as shell-like chunks when present.
|
|
58
|
+
if stdout and len(stdout) > 0:
|
|
59
|
+
emit(
|
|
60
|
+
HostEvent(
|
|
61
|
+
HostEventKind.SHELL_CHUNK,
|
|
62
|
+
_truncate(stdout, 4000),
|
|
63
|
+
meta={"stream": "stdout", "source": "codeact"},
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
if stderr:
|
|
67
|
+
emit(
|
|
68
|
+
HostEvent(
|
|
69
|
+
HostEventKind.SHELL_CHUNK,
|
|
70
|
+
_truncate(stderr, 2000),
|
|
71
|
+
meta={"stream": "stderr", "source": "codeact"},
|
|
72
|
+
)
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def on_error(event: Any) -> None:
|
|
76
|
+
content = str(getattr(event, "content", event) or "")
|
|
77
|
+
if content:
|
|
78
|
+
emit(HostEvent(HostEventKind.ERROR, content))
|
|
79
|
+
|
|
80
|
+
def on_llm_start(event: Any) -> None:
|
|
81
|
+
method = getattr(event, "method_name", "")
|
|
82
|
+
turn = getattr(event, "turn_number", "")
|
|
83
|
+
emit(
|
|
84
|
+
HostEvent(
|
|
85
|
+
HostEventKind.STATUS,
|
|
86
|
+
f"llm · {method} turn {turn}",
|
|
87
|
+
meta={"kind": "llm_start"},
|
|
88
|
+
)
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
def on_llm_end(event: Any) -> None:
|
|
92
|
+
ok = getattr(event, "success", True)
|
|
93
|
+
emit(
|
|
94
|
+
HostEvent(
|
|
95
|
+
HostEventKind.STATUS,
|
|
96
|
+
f"llm · {'ok' if ok else 'failed'}",
|
|
97
|
+
meta={"kind": "llm_end"},
|
|
98
|
+
)
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
def on_summary(event: Any) -> None:
|
|
102
|
+
text = str(getattr(event, "content", "") or getattr(event, "summary", "") or "")
|
|
103
|
+
if text:
|
|
104
|
+
emit(HostEvent(HostEventKind.SUMMARY, _truncate(text, 2000)))
|
|
105
|
+
|
|
106
|
+
for etype, handler in (
|
|
107
|
+
("ToolCallEvent", on_tool_call),
|
|
108
|
+
("PythonOutput", on_python_output),
|
|
109
|
+
("Error", on_error),
|
|
110
|
+
("LLMCallStart", on_llm_start),
|
|
111
|
+
("LLMCallEnd", on_llm_end),
|
|
112
|
+
("Summary", on_summary),
|
|
113
|
+
):
|
|
114
|
+
try:
|
|
115
|
+
unsubs.append(em.on(etype, handler))
|
|
116
|
+
except Exception: # noqa: BLE001 - event type may be unregistered
|
|
117
|
+
continue
|
|
118
|
+
|
|
119
|
+
return unsubs
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _brief_args(args: dict[str, Any]) -> str:
|
|
123
|
+
if not args:
|
|
124
|
+
return ""
|
|
125
|
+
keys = list(args.keys())[:3]
|
|
126
|
+
return ", ".join(f"{k}=…" for k in keys)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _truncate(text: str, limit: int) -> str:
|
|
130
|
+
if len(text) <= limit:
|
|
131
|
+
return text
|
|
132
|
+
return text[: limit // 2] + "\n…\n" + text[-(limit // 2) :]
|
noah_code/events.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Host-facing event types for UI rendering."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class HostEventKind(StrEnum):
|
|
11
|
+
MESSAGE = "message"
|
|
12
|
+
REASONING = "reasoning"
|
|
13
|
+
TOOL_START = "tool_start"
|
|
14
|
+
TOOL_FINISH = "tool_finish"
|
|
15
|
+
SHELL_CHUNK = "shell_chunk"
|
|
16
|
+
ERROR = "error"
|
|
17
|
+
SUMMARY = "summary"
|
|
18
|
+
APPROVAL = "approval"
|
|
19
|
+
STATUS = "status"
|
|
20
|
+
STOP = "stop"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class HostEvent:
|
|
25
|
+
kind: HostEventKind
|
|
26
|
+
text: str = ""
|
|
27
|
+
meta: dict[str, Any] = field(default_factory=dict)
|