krons 0.1.1__py3-none-any.whl → 0.2.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.
- krons/__init__.py +49 -0
- krons/agent/__init__.py +144 -0
- krons/agent/mcps/__init__.py +14 -0
- krons/agent/mcps/loader.py +287 -0
- krons/agent/mcps/wrapper.py +799 -0
- krons/agent/message/__init__.py +20 -0
- krons/agent/message/action.py +69 -0
- krons/agent/message/assistant.py +52 -0
- krons/agent/message/common.py +49 -0
- krons/agent/message/instruction.py +130 -0
- krons/agent/message/prepare_msg.py +187 -0
- krons/agent/message/role.py +53 -0
- krons/agent/message/system.py +53 -0
- krons/agent/operations/__init__.py +82 -0
- krons/agent/operations/act.py +100 -0
- krons/agent/operations/generate.py +145 -0
- krons/agent/operations/llm_reparse.py +89 -0
- krons/agent/operations/operate.py +247 -0
- krons/agent/operations/parse.py +243 -0
- krons/agent/operations/react.py +286 -0
- krons/agent/operations/specs.py +235 -0
- krons/agent/operations/structure.py +151 -0
- krons/agent/operations/utils.py +79 -0
- krons/agent/providers/__init__.py +17 -0
- krons/agent/providers/anthropic_messages.py +146 -0
- krons/agent/providers/claude_code.py +276 -0
- krons/agent/providers/gemini.py +268 -0
- krons/agent/providers/match.py +75 -0
- krons/agent/providers/oai_chat.py +174 -0
- krons/agent/third_party/__init__.py +2 -0
- krons/agent/third_party/anthropic_models.py +154 -0
- krons/agent/third_party/claude_code.py +682 -0
- krons/agent/third_party/gemini_models.py +508 -0
- krons/agent/third_party/openai_models.py +295 -0
- krons/agent/tool.py +291 -0
- krons/core/__init__.py +56 -74
- krons/core/base/__init__.py +121 -0
- krons/core/{broadcaster.py → base/broadcaster.py} +7 -3
- krons/core/{element.py → base/element.py} +13 -5
- krons/core/{event.py → base/event.py} +39 -6
- krons/core/{eventbus.py → base/eventbus.py} +3 -1
- krons/core/{flow.py → base/flow.py} +11 -4
- krons/core/{graph.py → base/graph.py} +24 -8
- krons/core/{node.py → base/node.py} +44 -19
- krons/core/{pile.py → base/pile.py} +22 -8
- krons/core/{processor.py → base/processor.py} +21 -7
- krons/core/{progression.py → base/progression.py} +3 -1
- krons/{specs → core/specs}/__init__.py +0 -5
- krons/{specs → core/specs}/adapters/dataclass_field.py +16 -8
- krons/{specs → core/specs}/adapters/pydantic_adapter.py +11 -5
- krons/{specs → core/specs}/adapters/sql_ddl.py +14 -8
- krons/{specs → core/specs}/catalog/__init__.py +2 -2
- krons/{specs → core/specs}/catalog/_audit.py +2 -2
- krons/{specs → core/specs}/catalog/_common.py +2 -2
- krons/{specs → core/specs}/catalog/_content.py +4 -4
- krons/{specs → core/specs}/catalog/_enforcement.py +3 -3
- krons/{specs → core/specs}/factory.py +5 -5
- krons/{specs → core/specs}/operable.py +8 -2
- krons/{specs → core/specs}/protocol.py +4 -2
- krons/{specs → core/specs}/spec.py +23 -11
- krons/{types → core/types}/base.py +4 -2
- krons/{types → core/types}/db_types.py +2 -2
- krons/errors.py +13 -13
- krons/protocols.py +9 -4
- krons/resource/__init__.py +89 -0
- krons/{services → resource}/backend.py +48 -22
- krons/{services → resource}/endpoint.py +28 -14
- krons/{services → resource}/hook.py +20 -7
- krons/{services → resource}/imodel.py +46 -28
- krons/{services → resource}/registry.py +26 -24
- krons/{services → resource}/utilities/rate_limited_executor.py +7 -3
- krons/{services → resource}/utilities/rate_limiter.py +3 -1
- krons/{services → resource}/utilities/resilience.py +15 -5
- krons/resource/utilities/token_calculator.py +185 -0
- krons/session/__init__.py +12 -17
- krons/session/constraints.py +70 -0
- krons/session/exchange.py +11 -3
- krons/session/message.py +3 -1
- krons/session/registry.py +35 -0
- krons/session/session.py +165 -174
- krons/utils/__init__.py +45 -0
- krons/utils/_function_arg_parser.py +99 -0
- krons/utils/_pythonic_function_call.py +249 -0
- krons/utils/_to_list.py +9 -3
- krons/utils/_utils.py +6 -2
- krons/utils/concurrency/_async_call.py +4 -2
- krons/utils/concurrency/_errors.py +3 -1
- krons/utils/concurrency/_patterns.py +3 -1
- krons/utils/concurrency/_resource_tracker.py +6 -2
- krons/utils/display.py +257 -0
- krons/utils/fuzzy/__init__.py +6 -1
- krons/utils/fuzzy/_fuzzy_match.py +14 -8
- krons/utils/fuzzy/_string_similarity.py +3 -1
- krons/utils/fuzzy/_to_dict.py +3 -1
- krons/utils/schemas/__init__.py +26 -0
- krons/utils/schemas/_breakdown_pydantic_annotation.py +131 -0
- krons/utils/schemas/_formatter.py +72 -0
- krons/utils/schemas/_minimal_yaml.py +151 -0
- krons/utils/schemas/_typescript.py +153 -0
- krons/utils/validators/__init__.py +3 -0
- krons/utils/validators/_validate_image_url.py +56 -0
- krons/work/__init__.py +126 -0
- krons/work/engine.py +333 -0
- krons/work/form.py +305 -0
- krons/{operations → work/operations}/__init__.py +7 -4
- krons/{operations → work/operations}/builder.py +1 -1
- krons/{enforcement → work/operations}/context.py +36 -5
- krons/{operations → work/operations}/flow.py +13 -5
- krons/{operations → work/operations}/node.py +45 -43
- krons/work/operations/registry.py +103 -0
- krons/{specs → work}/phrase.py +130 -13
- krons/{enforcement → work}/policy.py +3 -3
- krons/work/report.py +268 -0
- krons/work/rules/__init__.py +47 -0
- krons/{enforcement → work/rules}/common/boolean.py +3 -1
- krons/{enforcement → work/rules}/common/choice.py +9 -3
- krons/{enforcement → work/rules}/common/number.py +3 -1
- krons/{enforcement → work/rules}/common/string.py +9 -3
- krons/{enforcement → work/rules}/rule.py +1 -1
- krons/{enforcement → work/rules}/validator.py +20 -5
- krons/{enforcement → work}/service.py +16 -7
- krons/work/worker.py +266 -0
- {krons-0.1.1.dist-info → krons-0.2.0.dist-info}/METADATA +15 -1
- krons-0.2.0.dist-info/RECORD +154 -0
- krons/enforcement/__init__.py +0 -57
- krons/operations/registry.py +0 -92
- krons/services/__init__.py +0 -81
- krons-0.1.1.dist-info/RECORD +0 -101
- /krons/{specs → core/specs}/adapters/__init__.py +0 -0
- /krons/{specs → core/specs}/adapters/_utils.py +0 -0
- /krons/{specs → core/specs}/adapters/factory.py +0 -0
- /krons/{types → core/types}/__init__.py +0 -0
- /krons/{types → core/types}/_sentinel.py +0 -0
- /krons/{types → core/types}/identity.py +0 -0
- /krons/{services → resource}/utilities/__init__.py +0 -0
- /krons/{services → resource}/utilities/header_factory.py +0 -0
- /krons/{enforcement → work/rules}/common/__init__.py +0 -0
- /krons/{enforcement → work/rules}/common/mapping.py +0 -0
- /krons/{enforcement → work/rules}/common/model.py +0 -0
- /krons/{enforcement → work/rules}/registry.py +0 -0
- {krons-0.1.1.dist-info → krons-0.2.0.dist-info}/WHEEL +0 -0
- {krons-0.1.1.dist-info → krons-0.2.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
# Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import asyncio # Required for subprocess management (create_subprocess_exec)
|
|
7
|
+
import codecs
|
|
8
|
+
import contextlib
|
|
9
|
+
import inspect
|
|
10
|
+
import json # Required for JSONDecoder.raw_decode() (streaming JSON parsing)
|
|
11
|
+
import logging
|
|
12
|
+
import shutil
|
|
13
|
+
import warnings
|
|
14
|
+
from collections.abc import AsyncIterator, Callable
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from dataclasses import field as datafield
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from textwrap import shorten
|
|
19
|
+
from typing import Any, Literal
|
|
20
|
+
|
|
21
|
+
from pydantic import BaseModel, Field, model_validator
|
|
22
|
+
|
|
23
|
+
from krons.utils import json_dump
|
|
24
|
+
|
|
25
|
+
HAS_GEMINI_CLI = False
|
|
26
|
+
GEMINI_CLI = None
|
|
27
|
+
|
|
28
|
+
if (g := (shutil.which("gemini") or "gemini")) and shutil.which(g):
|
|
29
|
+
HAS_GEMINI_CLI = True
|
|
30
|
+
GEMINI_CLI = g
|
|
31
|
+
|
|
32
|
+
logging.basicConfig(level=logging.INFO)
|
|
33
|
+
log = logging.getLogger("gemini-cli")
|
|
34
|
+
|
|
35
|
+
__all__ = (
|
|
36
|
+
"GeminiChunk",
|
|
37
|
+
"GeminiCodeRequest",
|
|
38
|
+
"GeminiSession",
|
|
39
|
+
"stream_gemini_cli",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class GeminiCodeRequest(BaseModel):
|
|
44
|
+
"""Request model for Gemini CLI execution."""
|
|
45
|
+
|
|
46
|
+
# -- conversational bits -------------------------------------------------
|
|
47
|
+
prompt: str = Field(description="The prompt for Gemini CLI")
|
|
48
|
+
system_prompt: str | None = None
|
|
49
|
+
|
|
50
|
+
# -- repo / workspace ----------------------------------------------------
|
|
51
|
+
repo: Path = Field(default_factory=Path.cwd, exclude=True)
|
|
52
|
+
ws: str | None = None # sub-directory under repo
|
|
53
|
+
include_directories: list[str] = Field(default_factory=list)
|
|
54
|
+
|
|
55
|
+
# -- runtime & safety ----------------------------------------------------
|
|
56
|
+
model: str | None = Field(
|
|
57
|
+
default="gemini-2.5-pro",
|
|
58
|
+
description="Gemini model to use (gemini-2.5-pro, gemini-2.5-flash, gemini-3-pro, etc.)",
|
|
59
|
+
)
|
|
60
|
+
yolo: bool = Field(
|
|
61
|
+
default=False,
|
|
62
|
+
description="Auto-approve all actions without confirmation (--yolo flag)",
|
|
63
|
+
)
|
|
64
|
+
approval_mode: Literal["suggest", "auto_edit", "full_auto"] | None = None
|
|
65
|
+
debug: bool = False
|
|
66
|
+
sandbox: bool = Field(
|
|
67
|
+
default=True,
|
|
68
|
+
description="Run in sandbox mode for safety",
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# -- MCP integration -----------------------------------------------------
|
|
72
|
+
mcp_tools: list[str] = Field(default_factory=list)
|
|
73
|
+
|
|
74
|
+
# -- internal use --------------------------------------------------------
|
|
75
|
+
verbose_output: bool = Field(default=False)
|
|
76
|
+
cli_include_summary: bool = Field(default=False)
|
|
77
|
+
|
|
78
|
+
@model_validator(mode="before")
|
|
79
|
+
@classmethod
|
|
80
|
+
def _validate_message_prompt(cls, data):
|
|
81
|
+
"""Convert messages format to prompt if needed."""
|
|
82
|
+
if data.get("prompt"):
|
|
83
|
+
return data
|
|
84
|
+
|
|
85
|
+
if not (msg := data.get("messages")):
|
|
86
|
+
raise ValueError("messages or prompt required")
|
|
87
|
+
|
|
88
|
+
# Extract prompt from messages
|
|
89
|
+
prompts = []
|
|
90
|
+
for message in msg:
|
|
91
|
+
if message["role"] != "system":
|
|
92
|
+
content = message["content"]
|
|
93
|
+
if isinstance(content, (dict, list)):
|
|
94
|
+
prompts.append(json_dump(content))
|
|
95
|
+
else:
|
|
96
|
+
prompts.append(content)
|
|
97
|
+
elif message["role"] == "system" and not data.get("system_prompt"):
|
|
98
|
+
data["system_prompt"] = message["content"]
|
|
99
|
+
|
|
100
|
+
data["prompt"] = "\n".join(prompts)
|
|
101
|
+
return data
|
|
102
|
+
|
|
103
|
+
@model_validator(mode="after")
|
|
104
|
+
def _warn_dangerous_settings(self):
|
|
105
|
+
"""Emit security warnings for dangerous CLI settings."""
|
|
106
|
+
if self.yolo:
|
|
107
|
+
warnings.warn(
|
|
108
|
+
"GeminiCodeRequest: yolo=True enables auto-approval of ALL actions "
|
|
109
|
+
"without confirmation. This bypasses safety prompts and may allow "
|
|
110
|
+
"unintended file modifications, command execution, or data access. "
|
|
111
|
+
"Only use in trusted, isolated environments.",
|
|
112
|
+
UserWarning,
|
|
113
|
+
stacklevel=4,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
if not self.sandbox:
|
|
117
|
+
warnings.warn(
|
|
118
|
+
"GeminiCodeRequest: sandbox=False disables sandbox protection. "
|
|
119
|
+
"The Gemini CLI will have unrestricted access to the file system "
|
|
120
|
+
"and can execute arbitrary commands. This significantly increases "
|
|
121
|
+
"security risk. Only disable sandbox in controlled environments.",
|
|
122
|
+
UserWarning,
|
|
123
|
+
stacklevel=4,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
return self
|
|
127
|
+
|
|
128
|
+
def cwd(self) -> Path:
|
|
129
|
+
"""Get working directory, validating workspace path."""
|
|
130
|
+
if not self.ws:
|
|
131
|
+
return self.repo
|
|
132
|
+
|
|
133
|
+
ws_path = Path(self.ws)
|
|
134
|
+
|
|
135
|
+
if ws_path.is_absolute():
|
|
136
|
+
raise ValueError(
|
|
137
|
+
f"Workspace path must be relative, got absolute: {self.ws}"
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
if ".." in ws_path.parts:
|
|
141
|
+
raise ValueError(
|
|
142
|
+
f"Directory traversal detected in workspace path: {self.ws}"
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
repo_resolved = self.repo.resolve()
|
|
146
|
+
result = (self.repo / ws_path).resolve()
|
|
147
|
+
|
|
148
|
+
try:
|
|
149
|
+
result.relative_to(repo_resolved)
|
|
150
|
+
except ValueError:
|
|
151
|
+
raise ValueError(
|
|
152
|
+
f"Workspace path escapes repository bounds. "
|
|
153
|
+
f"Repository: {repo_resolved}, Workspace: {result}"
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
return result
|
|
157
|
+
|
|
158
|
+
def as_cmd_args(self) -> list[str]:
|
|
159
|
+
"""Build argument list for the Gemini CLI."""
|
|
160
|
+
args: list[str] = ["-p", self.prompt, "--output-format", "stream-json"]
|
|
161
|
+
|
|
162
|
+
if self.model:
|
|
163
|
+
args += ["-m", self.model]
|
|
164
|
+
|
|
165
|
+
if self.yolo:
|
|
166
|
+
args.append("--yolo")
|
|
167
|
+
|
|
168
|
+
if self.approval_mode:
|
|
169
|
+
args += ["--approval-mode", self.approval_mode]
|
|
170
|
+
|
|
171
|
+
if self.debug:
|
|
172
|
+
args.append("--debug")
|
|
173
|
+
|
|
174
|
+
if not self.sandbox:
|
|
175
|
+
args.append("--no-sandbox")
|
|
176
|
+
|
|
177
|
+
for directory in self.include_directories:
|
|
178
|
+
args += ["--include-directories", directory]
|
|
179
|
+
|
|
180
|
+
return args
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@dataclass
|
|
184
|
+
class GeminiChunk:
|
|
185
|
+
"""Low-level wrapper around every JSON object from the CLI."""
|
|
186
|
+
|
|
187
|
+
raw: dict[str, Any]
|
|
188
|
+
type: str
|
|
189
|
+
# convenience views
|
|
190
|
+
text: str | None = None
|
|
191
|
+
tool_use: dict[str, Any] | None = None
|
|
192
|
+
tool_result: dict[str, Any] | None = None
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@dataclass
|
|
196
|
+
class GeminiSession:
|
|
197
|
+
"""Aggregated view of a whole CLI conversation."""
|
|
198
|
+
|
|
199
|
+
session_id: str | None = None
|
|
200
|
+
model: str | None = None
|
|
201
|
+
|
|
202
|
+
# chronological log
|
|
203
|
+
chunks: list[GeminiChunk] = datafield(default_factory=list)
|
|
204
|
+
|
|
205
|
+
# materialized views
|
|
206
|
+
messages: list[dict[str, Any]] = datafield(default_factory=list)
|
|
207
|
+
tool_uses: list[dict[str, Any]] = datafield(default_factory=list)
|
|
208
|
+
tool_results: list[dict[str, Any]] = datafield(default_factory=list)
|
|
209
|
+
|
|
210
|
+
# final summary
|
|
211
|
+
result: str = ""
|
|
212
|
+
usage: dict[str, Any] = datafield(default_factory=dict)
|
|
213
|
+
total_cost_usd: float | None = None
|
|
214
|
+
num_turns: int | None = None
|
|
215
|
+
duration_ms: int | None = None
|
|
216
|
+
is_error: bool = False
|
|
217
|
+
summary: dict | None = None
|
|
218
|
+
|
|
219
|
+
def populate_summary(self) -> None:
|
|
220
|
+
self.summary = _extract_summary(self)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _extract_summary(session: GeminiSession) -> dict[str, Any]:
|
|
224
|
+
"""Extract summary from session data."""
|
|
225
|
+
tool_counts: dict[str, int] = {}
|
|
226
|
+
tool_details: list[dict[str, Any]] = []
|
|
227
|
+
file_operations: dict[str, list[str]] = {"reads": [], "writes": [], "edits": []}
|
|
228
|
+
key_actions = []
|
|
229
|
+
|
|
230
|
+
for tool_use in session.tool_uses:
|
|
231
|
+
tool_name = tool_use.get("name", "unknown")
|
|
232
|
+
tool_input = tool_use.get("input", {})
|
|
233
|
+
tool_id = tool_use.get("id", "")
|
|
234
|
+
|
|
235
|
+
tool_counts[tool_name] = tool_counts.get(tool_name, 0) + 1
|
|
236
|
+
tool_details.append({"tool": tool_name, "id": tool_id, "input": tool_input})
|
|
237
|
+
|
|
238
|
+
# Categorize by tool type
|
|
239
|
+
if tool_name in ["read_file", "Read"]:
|
|
240
|
+
file_path = tool_input.get("path", tool_input.get("file_path", "unknown"))
|
|
241
|
+
file_operations["reads"].append(file_path)
|
|
242
|
+
key_actions.append(f"Read {file_path}")
|
|
243
|
+
|
|
244
|
+
elif tool_name in ["write_file", "Write"]:
|
|
245
|
+
file_path = tool_input.get("path", tool_input.get("file_path", "unknown"))
|
|
246
|
+
file_operations["writes"].append(file_path)
|
|
247
|
+
key_actions.append(f"Wrote {file_path}")
|
|
248
|
+
|
|
249
|
+
elif tool_name in ["edit_file", "Edit"]:
|
|
250
|
+
file_path = tool_input.get("path", tool_input.get("file_path", "unknown"))
|
|
251
|
+
file_operations["edits"].append(file_path)
|
|
252
|
+
key_actions.append(f"Edited {file_path}")
|
|
253
|
+
|
|
254
|
+
elif tool_name in ["run_shell_command", "shell", "Bash"]:
|
|
255
|
+
command = tool_input.get("command", "")
|
|
256
|
+
command_summary = command[:50] + "..." if len(command) > 50 else command
|
|
257
|
+
key_actions.append(f"Ran: {command_summary}")
|
|
258
|
+
|
|
259
|
+
elif tool_name.startswith("mcp_"):
|
|
260
|
+
operation = tool_name.replace("mcp_", "")
|
|
261
|
+
key_actions.append(f"MCP {operation}")
|
|
262
|
+
|
|
263
|
+
else:
|
|
264
|
+
key_actions.append(f"Used {tool_name}")
|
|
265
|
+
|
|
266
|
+
key_actions = (
|
|
267
|
+
list(dict.fromkeys(key_actions)) if key_actions else ["No specific actions"]
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
for op_type in file_operations:
|
|
271
|
+
file_operations[op_type] = list(dict.fromkeys(file_operations[op_type]))
|
|
272
|
+
|
|
273
|
+
result_summary = (
|
|
274
|
+
(session.result[:200] + "...") if len(session.result) > 200 else session.result
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
return {
|
|
278
|
+
"tool_counts": tool_counts,
|
|
279
|
+
"tool_details": tool_details,
|
|
280
|
+
"file_operations": file_operations,
|
|
281
|
+
"key_actions": key_actions,
|
|
282
|
+
"total_tool_calls": sum(tool_counts.values()),
|
|
283
|
+
"result_summary": result_summary,
|
|
284
|
+
"usage_stats": {
|
|
285
|
+
"total_cost_usd": session.total_cost_usd,
|
|
286
|
+
"num_turns": session.num_turns,
|
|
287
|
+
"duration_ms": session.duration_ms,
|
|
288
|
+
**session.usage,
|
|
289
|
+
},
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
async def _ndjson_from_cli(request: GeminiCodeRequest):
|
|
294
|
+
"""
|
|
295
|
+
Yields each JSON object emitted by the Gemini CLI.
|
|
296
|
+
|
|
297
|
+
Robust against UTF-8 splits and uses json.JSONDecoder.raw_decode.
|
|
298
|
+
"""
|
|
299
|
+
if GEMINI_CLI is None:
|
|
300
|
+
raise RuntimeError("Gemini CLI not found. Please install the gemini CLI tool.")
|
|
301
|
+
|
|
302
|
+
workspace = request.cwd()
|
|
303
|
+
workspace.mkdir(parents=True, exist_ok=True)
|
|
304
|
+
|
|
305
|
+
proc = await asyncio.create_subprocess_exec(
|
|
306
|
+
GEMINI_CLI,
|
|
307
|
+
*request.as_cmd_args(),
|
|
308
|
+
cwd=str(workspace),
|
|
309
|
+
stdout=asyncio.subprocess.PIPE,
|
|
310
|
+
stderr=asyncio.subprocess.PIPE,
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
decoder = codecs.getincrementaldecoder("utf-8")()
|
|
314
|
+
json_decoder = json.JSONDecoder()
|
|
315
|
+
buffer: str = ""
|
|
316
|
+
|
|
317
|
+
if proc.stdout is None:
|
|
318
|
+
raise RuntimeError("Failed to capture stdout from Gemini CLI")
|
|
319
|
+
|
|
320
|
+
try:
|
|
321
|
+
while True:
|
|
322
|
+
chunk = await proc.stdout.read(4096)
|
|
323
|
+
if not chunk:
|
|
324
|
+
break
|
|
325
|
+
|
|
326
|
+
buffer += decoder.decode(chunk)
|
|
327
|
+
|
|
328
|
+
while buffer:
|
|
329
|
+
buffer = buffer.lstrip()
|
|
330
|
+
if not buffer:
|
|
331
|
+
break
|
|
332
|
+
try:
|
|
333
|
+
obj, idx = json_decoder.raw_decode(buffer)
|
|
334
|
+
yield obj
|
|
335
|
+
buffer = buffer[idx:]
|
|
336
|
+
except json.JSONDecodeError:
|
|
337
|
+
break
|
|
338
|
+
|
|
339
|
+
buffer += decoder.decode(b"", final=True)
|
|
340
|
+
buffer = buffer.strip()
|
|
341
|
+
if buffer:
|
|
342
|
+
try:
|
|
343
|
+
obj, idx = json_decoder.raw_decode(buffer)
|
|
344
|
+
yield obj
|
|
345
|
+
except json.JSONDecodeError:
|
|
346
|
+
log.error("Skipped unrecoverable JSON tail: %.120s...", buffer)
|
|
347
|
+
|
|
348
|
+
if await proc.wait() != 0:
|
|
349
|
+
err = ""
|
|
350
|
+
if proc.stderr is not None:
|
|
351
|
+
err = (await proc.stderr.read()).decode().strip()
|
|
352
|
+
raise RuntimeError(err or "Gemini CLI exited non-zero")
|
|
353
|
+
|
|
354
|
+
finally:
|
|
355
|
+
with contextlib.suppress(ProcessLookupError):
|
|
356
|
+
proc.terminate()
|
|
357
|
+
await proc.wait()
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
async def stream_gemini_cli_events(request: GeminiCodeRequest):
|
|
361
|
+
"""Stream events from Gemini CLI."""
|
|
362
|
+
if not GEMINI_CLI:
|
|
363
|
+
raise RuntimeError("Gemini CLI not found (npm i -g @google/gemini-cli)")
|
|
364
|
+
async for obj in _ndjson_from_cli(request):
|
|
365
|
+
yield obj
|
|
366
|
+
yield {"type": "done"}
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
async def _maybe_await(func, *args, **kw):
|
|
370
|
+
"""Call func which may be sync or async."""
|
|
371
|
+
res = func(*args, **kw) if func else None
|
|
372
|
+
if inspect.iscoroutine(res):
|
|
373
|
+
await res
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _pp_text(text: str) -> None:
|
|
377
|
+
print(f"\n> Gemini:\n{text}\n")
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _pp_tool_use(tu: dict[str, Any]) -> None:
|
|
381
|
+
preview = shorten(str(tu.get("input", {})).replace("\n", " "), 130)
|
|
382
|
+
print(f"- Tool Use - {tu.get('name', 'unknown')}: {preview}")
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _pp_tool_result(tr: dict[str, Any]) -> None:
|
|
386
|
+
body_preview = shorten(str(tr.get("content", "")).replace("\n", " "), 130)
|
|
387
|
+
status = "ERR" if tr.get("is_error") else "OK"
|
|
388
|
+
print(f"- Tool Result - {status}: {body_preview}")
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _pp_final(sess: GeminiSession) -> None:
|
|
392
|
+
usage = sess.usage or {}
|
|
393
|
+
print(
|
|
394
|
+
f"\n### Session complete\n"
|
|
395
|
+
f"**Result:** {sess.result or ''}\n"
|
|
396
|
+
f"- turns: {sess.num_turns}\n"
|
|
397
|
+
f"- duration: {sess.duration_ms} ms\n"
|
|
398
|
+
f"- tokens: {usage.get('input_tokens', 0)}/{usage.get('output_tokens', 0)}"
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
async def stream_gemini_cli(
|
|
403
|
+
request: GeminiCodeRequest,
|
|
404
|
+
session: GeminiSession | None = None,
|
|
405
|
+
*,
|
|
406
|
+
on_text: Callable[[str], None] | None = None,
|
|
407
|
+
on_tool_use: Callable[[dict[str, Any]], None] | None = None,
|
|
408
|
+
on_tool_result: Callable[[dict[str, Any]], None] | None = None,
|
|
409
|
+
on_final: Callable[[GeminiSession], None] | None = None,
|
|
410
|
+
) -> AsyncIterator[GeminiChunk | dict | GeminiSession]:
|
|
411
|
+
"""
|
|
412
|
+
Consume the ND-JSON stream from Gemini CLI and return a populated GeminiSession.
|
|
413
|
+
"""
|
|
414
|
+
if session is None:
|
|
415
|
+
session = GeminiSession()
|
|
416
|
+
|
|
417
|
+
stream = stream_gemini_cli_events(request)
|
|
418
|
+
|
|
419
|
+
async for obj in stream:
|
|
420
|
+
typ = obj.get("type", "unknown")
|
|
421
|
+
chunk = GeminiChunk(raw=obj, type=typ)
|
|
422
|
+
session.chunks.append(chunk)
|
|
423
|
+
|
|
424
|
+
# Handle different event types based on Gemini CLI output format
|
|
425
|
+
if typ in ("system", "init"):
|
|
426
|
+
session.session_id = obj.get("session_id", obj.get("id"))
|
|
427
|
+
session.model = obj.get("model")
|
|
428
|
+
yield obj
|
|
429
|
+
|
|
430
|
+
elif typ in ("message", "assistant"):
|
|
431
|
+
msg = obj.get("message", obj)
|
|
432
|
+
session.messages.append(msg)
|
|
433
|
+
|
|
434
|
+
content = msg.get("content", "")
|
|
435
|
+
if isinstance(content, str):
|
|
436
|
+
chunk.text = content
|
|
437
|
+
await _maybe_await(on_text, content)
|
|
438
|
+
if request.verbose_output:
|
|
439
|
+
_pp_text(content)
|
|
440
|
+
elif isinstance(content, list):
|
|
441
|
+
for blk in content:
|
|
442
|
+
if isinstance(blk, dict):
|
|
443
|
+
btype = blk.get("type")
|
|
444
|
+
if btype == "text":
|
|
445
|
+
text = blk.get("text", "")
|
|
446
|
+
chunk.text = text
|
|
447
|
+
await _maybe_await(on_text, text)
|
|
448
|
+
if request.verbose_output:
|
|
449
|
+
_pp_text(text)
|
|
450
|
+
elif btype == "tool_use":
|
|
451
|
+
tu = {
|
|
452
|
+
"id": blk.get("id", ""),
|
|
453
|
+
"name": blk.get("name", ""),
|
|
454
|
+
"input": blk.get("input", {}),
|
|
455
|
+
}
|
|
456
|
+
chunk.tool_use = tu
|
|
457
|
+
session.tool_uses.append(tu)
|
|
458
|
+
await _maybe_await(on_tool_use, tu)
|
|
459
|
+
if request.verbose_output:
|
|
460
|
+
_pp_tool_use(tu)
|
|
461
|
+
yield chunk
|
|
462
|
+
|
|
463
|
+
elif typ in ("tool_call", "tool_use"):
|
|
464
|
+
tu = {
|
|
465
|
+
"id": obj.get("id", obj.get("tool_use_id", "")),
|
|
466
|
+
"name": obj.get("name", obj.get("tool_name", "")),
|
|
467
|
+
"input": obj.get("input", obj.get("args", {})),
|
|
468
|
+
}
|
|
469
|
+
chunk.tool_use = tu
|
|
470
|
+
session.tool_uses.append(tu)
|
|
471
|
+
await _maybe_await(on_tool_use, tu)
|
|
472
|
+
if request.verbose_output:
|
|
473
|
+
_pp_tool_use(tu)
|
|
474
|
+
yield chunk
|
|
475
|
+
|
|
476
|
+
elif typ == "tool_result":
|
|
477
|
+
tr = {
|
|
478
|
+
"tool_use_id": obj.get("tool_use_id", obj.get("id", "")),
|
|
479
|
+
"content": obj.get("content", obj.get("result", "")),
|
|
480
|
+
"is_error": obj.get("is_error", False),
|
|
481
|
+
}
|
|
482
|
+
chunk.tool_result = tr
|
|
483
|
+
session.tool_results.append(tr)
|
|
484
|
+
await _maybe_await(on_tool_result, tr)
|
|
485
|
+
if request.verbose_output:
|
|
486
|
+
_pp_tool_result(tr)
|
|
487
|
+
yield chunk
|
|
488
|
+
|
|
489
|
+
elif typ in ("result", "response"):
|
|
490
|
+
session.result = obj.get("result", obj.get("response", "")).strip()
|
|
491
|
+
session.usage = obj.get("usage", obj.get("stats", {}))
|
|
492
|
+
session.total_cost_usd = obj.get("total_cost_usd", obj.get("cost"))
|
|
493
|
+
session.num_turns = obj.get("num_turns", obj.get("turns"))
|
|
494
|
+
session.duration_ms = obj.get("duration_ms", obj.get("duration"))
|
|
495
|
+
session.is_error = obj.get("is_error", obj.get("error") is not None)
|
|
496
|
+
|
|
497
|
+
elif typ == "error":
|
|
498
|
+
session.is_error = True
|
|
499
|
+
session.result = obj.get("message", obj.get("error", "Unknown error"))
|
|
500
|
+
|
|
501
|
+
elif typ == "done":
|
|
502
|
+
break
|
|
503
|
+
|
|
504
|
+
await _maybe_await(on_final, session)
|
|
505
|
+
if request.verbose_output:
|
|
506
|
+
_pp_final(session)
|
|
507
|
+
|
|
508
|
+
yield session
|