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.
Files changed (142) hide show
  1. krons/__init__.py +49 -0
  2. krons/agent/__init__.py +144 -0
  3. krons/agent/mcps/__init__.py +14 -0
  4. krons/agent/mcps/loader.py +287 -0
  5. krons/agent/mcps/wrapper.py +799 -0
  6. krons/agent/message/__init__.py +20 -0
  7. krons/agent/message/action.py +69 -0
  8. krons/agent/message/assistant.py +52 -0
  9. krons/agent/message/common.py +49 -0
  10. krons/agent/message/instruction.py +130 -0
  11. krons/agent/message/prepare_msg.py +187 -0
  12. krons/agent/message/role.py +53 -0
  13. krons/agent/message/system.py +53 -0
  14. krons/agent/operations/__init__.py +82 -0
  15. krons/agent/operations/act.py +100 -0
  16. krons/agent/operations/generate.py +145 -0
  17. krons/agent/operations/llm_reparse.py +89 -0
  18. krons/agent/operations/operate.py +247 -0
  19. krons/agent/operations/parse.py +243 -0
  20. krons/agent/operations/react.py +286 -0
  21. krons/agent/operations/specs.py +235 -0
  22. krons/agent/operations/structure.py +151 -0
  23. krons/agent/operations/utils.py +79 -0
  24. krons/agent/providers/__init__.py +17 -0
  25. krons/agent/providers/anthropic_messages.py +146 -0
  26. krons/agent/providers/claude_code.py +276 -0
  27. krons/agent/providers/gemini.py +268 -0
  28. krons/agent/providers/match.py +75 -0
  29. krons/agent/providers/oai_chat.py +174 -0
  30. krons/agent/third_party/__init__.py +2 -0
  31. krons/agent/third_party/anthropic_models.py +154 -0
  32. krons/agent/third_party/claude_code.py +682 -0
  33. krons/agent/third_party/gemini_models.py +508 -0
  34. krons/agent/third_party/openai_models.py +295 -0
  35. krons/agent/tool.py +291 -0
  36. krons/core/__init__.py +56 -74
  37. krons/core/base/__init__.py +121 -0
  38. krons/core/{broadcaster.py → base/broadcaster.py} +7 -3
  39. krons/core/{element.py → base/element.py} +13 -5
  40. krons/core/{event.py → base/event.py} +39 -6
  41. krons/core/{eventbus.py → base/eventbus.py} +3 -1
  42. krons/core/{flow.py → base/flow.py} +11 -4
  43. krons/core/{graph.py → base/graph.py} +24 -8
  44. krons/core/{node.py → base/node.py} +44 -19
  45. krons/core/{pile.py → base/pile.py} +22 -8
  46. krons/core/{processor.py → base/processor.py} +21 -7
  47. krons/core/{progression.py → base/progression.py} +3 -1
  48. krons/{specs → core/specs}/__init__.py +0 -5
  49. krons/{specs → core/specs}/adapters/dataclass_field.py +16 -8
  50. krons/{specs → core/specs}/adapters/pydantic_adapter.py +11 -5
  51. krons/{specs → core/specs}/adapters/sql_ddl.py +14 -8
  52. krons/{specs → core/specs}/catalog/__init__.py +2 -2
  53. krons/{specs → core/specs}/catalog/_audit.py +2 -2
  54. krons/{specs → core/specs}/catalog/_common.py +2 -2
  55. krons/{specs → core/specs}/catalog/_content.py +4 -4
  56. krons/{specs → core/specs}/catalog/_enforcement.py +3 -3
  57. krons/{specs → core/specs}/factory.py +5 -5
  58. krons/{specs → core/specs}/operable.py +8 -2
  59. krons/{specs → core/specs}/protocol.py +4 -2
  60. krons/{specs → core/specs}/spec.py +23 -11
  61. krons/{types → core/types}/base.py +4 -2
  62. krons/{types → core/types}/db_types.py +2 -2
  63. krons/errors.py +13 -13
  64. krons/protocols.py +9 -4
  65. krons/resource/__init__.py +89 -0
  66. krons/{services → resource}/backend.py +48 -22
  67. krons/{services → resource}/endpoint.py +28 -14
  68. krons/{services → resource}/hook.py +20 -7
  69. krons/{services → resource}/imodel.py +46 -28
  70. krons/{services → resource}/registry.py +26 -24
  71. krons/{services → resource}/utilities/rate_limited_executor.py +7 -3
  72. krons/{services → resource}/utilities/rate_limiter.py +3 -1
  73. krons/{services → resource}/utilities/resilience.py +15 -5
  74. krons/resource/utilities/token_calculator.py +185 -0
  75. krons/session/__init__.py +12 -17
  76. krons/session/constraints.py +70 -0
  77. krons/session/exchange.py +11 -3
  78. krons/session/message.py +3 -1
  79. krons/session/registry.py +35 -0
  80. krons/session/session.py +165 -174
  81. krons/utils/__init__.py +45 -0
  82. krons/utils/_function_arg_parser.py +99 -0
  83. krons/utils/_pythonic_function_call.py +249 -0
  84. krons/utils/_to_list.py +9 -3
  85. krons/utils/_utils.py +6 -2
  86. krons/utils/concurrency/_async_call.py +4 -2
  87. krons/utils/concurrency/_errors.py +3 -1
  88. krons/utils/concurrency/_patterns.py +3 -1
  89. krons/utils/concurrency/_resource_tracker.py +6 -2
  90. krons/utils/display.py +257 -0
  91. krons/utils/fuzzy/__init__.py +6 -1
  92. krons/utils/fuzzy/_fuzzy_match.py +14 -8
  93. krons/utils/fuzzy/_string_similarity.py +3 -1
  94. krons/utils/fuzzy/_to_dict.py +3 -1
  95. krons/utils/schemas/__init__.py +26 -0
  96. krons/utils/schemas/_breakdown_pydantic_annotation.py +131 -0
  97. krons/utils/schemas/_formatter.py +72 -0
  98. krons/utils/schemas/_minimal_yaml.py +151 -0
  99. krons/utils/schemas/_typescript.py +153 -0
  100. krons/utils/validators/__init__.py +3 -0
  101. krons/utils/validators/_validate_image_url.py +56 -0
  102. krons/work/__init__.py +126 -0
  103. krons/work/engine.py +333 -0
  104. krons/work/form.py +305 -0
  105. krons/{operations → work/operations}/__init__.py +7 -4
  106. krons/{operations → work/operations}/builder.py +1 -1
  107. krons/{enforcement → work/operations}/context.py +36 -5
  108. krons/{operations → work/operations}/flow.py +13 -5
  109. krons/{operations → work/operations}/node.py +45 -43
  110. krons/work/operations/registry.py +103 -0
  111. krons/{specs → work}/phrase.py +130 -13
  112. krons/{enforcement → work}/policy.py +3 -3
  113. krons/work/report.py +268 -0
  114. krons/work/rules/__init__.py +47 -0
  115. krons/{enforcement → work/rules}/common/boolean.py +3 -1
  116. krons/{enforcement → work/rules}/common/choice.py +9 -3
  117. krons/{enforcement → work/rules}/common/number.py +3 -1
  118. krons/{enforcement → work/rules}/common/string.py +9 -3
  119. krons/{enforcement → work/rules}/rule.py +1 -1
  120. krons/{enforcement → work/rules}/validator.py +20 -5
  121. krons/{enforcement → work}/service.py +16 -7
  122. krons/work/worker.py +266 -0
  123. {krons-0.1.1.dist-info → krons-0.2.0.dist-info}/METADATA +15 -1
  124. krons-0.2.0.dist-info/RECORD +154 -0
  125. krons/enforcement/__init__.py +0 -57
  126. krons/operations/registry.py +0 -92
  127. krons/services/__init__.py +0 -81
  128. krons-0.1.1.dist-info/RECORD +0 -101
  129. /krons/{specs → core/specs}/adapters/__init__.py +0 -0
  130. /krons/{specs → core/specs}/adapters/_utils.py +0 -0
  131. /krons/{specs → core/specs}/adapters/factory.py +0 -0
  132. /krons/{types → core/types}/__init__.py +0 -0
  133. /krons/{types → core/types}/_sentinel.py +0 -0
  134. /krons/{types → core/types}/identity.py +0 -0
  135. /krons/{services → resource}/utilities/__init__.py +0 -0
  136. /krons/{services → resource}/utilities/header_factory.py +0 -0
  137. /krons/{enforcement → work/rules}/common/__init__.py +0 -0
  138. /krons/{enforcement → work/rules}/common/mapping.py +0 -0
  139. /krons/{enforcement → work/rules}/common/model.py +0 -0
  140. /krons/{enforcement → work/rules}/registry.py +0 -0
  141. {krons-0.1.1.dist-info → krons-0.2.0.dist-info}/WHEEL +0 -0
  142. {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