ipygraph 1.14.2__tar.gz → 1.15.0__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.
Files changed (77) hide show
  1. ipygraph-1.15.0/.claude/launch.json +20 -0
  2. {ipygraph-1.14.2 → ipygraph-1.15.0}/.gitignore +79 -0
  3. ipygraph-1.15.0/.jupyter/collaboration_sessions.json +52 -0
  4. ipygraph-1.15.0/CHANGELOG.md +13 -0
  5. {ipygraph-1.14.2 → ipygraph-1.15.0}/PKG-INFO +25 -2
  6. {ipygraph-1.14.2 → ipygraph-1.15.0}/README.md +21 -0
  7. ipygraph-1.15.0/junit.xml +7 -0
  8. {ipygraph-1.14.2 → ipygraph-1.15.0}/myextension/_version.py +1 -1
  9. {ipygraph-1.14.2 → ipygraph-1.15.0}/myextension/agent.py +159 -38
  10. ipygraph-1.15.0/myextension/agent_providers.py +609 -0
  11. ipygraph-1.15.0/myextension/dag_runner.py +255 -0
  12. {ipygraph-1.14.2 → ipygraph-1.15.0}/myextension/handlers.py +3 -0
  13. ipygraph-1.15.0/myextension/labextension/build_log.json +739 -0
  14. {ipygraph-1.14.2 → ipygraph-1.15.0}/myextension/labextension/package.json +22 -23
  15. ipygraph-1.15.0/myextension/labextension/static/lib_index_js.c266c0a863afa8c3.js +12421 -0
  16. ipygraph-1.15.0/myextension/labextension/static/lib_index_js.c266c0a863afa8c3.js.map +1 -0
  17. ipygraph-1.15.0/myextension/labextension/static/remoteEntry.182ed622251c7e11.js +979 -0
  18. ipygraph-1.15.0/myextension/labextension/static/remoteEntry.182ed622251c7e11.js.map +1 -0
  19. ipygraph-1.15.0/myextension/labextension/static/style_index_js.e7f0aa441eb8fd67.js +1088 -0
  20. ipygraph-1.15.0/myextension/labextension/static/style_index_js.e7f0aa441eb8fd67.js.map +1 -0
  21. {ipygraph-1.14.2 → ipygraph-1.15.0}/package.json +22 -23
  22. {ipygraph-1.14.2 → ipygraph-1.15.0}/pyproject.toml +2 -0
  23. {ipygraph-1.14.2 → ipygraph-1.15.0}/src/agentApi.ts +99 -2
  24. {ipygraph-1.14.2 → ipygraph-1.15.0}/src/agentPanel.ts +885 -249
  25. {ipygraph-1.14.2 → ipygraph-1.15.0}/src/agentTools.ts +101 -31
  26. {ipygraph-1.14.2 → ipygraph-1.15.0}/src/branchSelector.ts +12 -0
  27. ipygraph-1.15.0/src/dagModel.ts +394 -0
  28. ipygraph-1.15.0/src/dagNodePanel.ts +458 -0
  29. ipygraph-1.15.0/src/dagView.ts +1107 -0
  30. ipygraph-1.15.0/src/experimentView.ts +549 -0
  31. {ipygraph-1.14.2 → ipygraph-1.15.0}/src/graphView.ts +45 -13
  32. {ipygraph-1.14.2 → ipygraph-1.15.0}/src/icon.ts +23 -0
  33. {ipygraph-1.14.2 → ipygraph-1.15.0}/src/index.ts +161 -14
  34. {ipygraph-1.14.2 → ipygraph-1.15.0}/src/parallel.ts +2 -2
  35. {ipygraph-1.14.2 → ipygraph-1.15.0}/src/widget.ts +7 -0
  36. ipygraph-1.15.0/style/base.css +670 -0
  37. {ipygraph-1.14.2 → ipygraph-1.15.0}/yarn.lock +913 -1445
  38. ipygraph-1.14.2/CHANGELOG.md +0 -5
  39. ipygraph-1.14.2/myextension/agent_providers.py +0 -326
  40. ipygraph-1.14.2/myextension/labextension/static/232.b7f55d8d983399ee.js +0 -59
  41. ipygraph-1.14.2/myextension/labextension/static/694.255a445d5c77b9d5.js +0 -495
  42. ipygraph-1.14.2/myextension/labextension/static/remoteEntry.7c24b058bfa39f37.js +0 -7
  43. ipygraph-1.14.2/myextension/labextension/static/third-party-licenses.json +0 -52
  44. ipygraph-1.14.2/style/base.css +0 -58
  45. {ipygraph-1.14.2 → ipygraph-1.15.0}/.copier-answers.yml +0 -0
  46. {ipygraph-1.14.2 → ipygraph-1.15.0}/.prettierignore +0 -0
  47. {ipygraph-1.14.2 → ipygraph-1.15.0}/.yarnrc.yml +0 -0
  48. {ipygraph-1.14.2 → ipygraph-1.15.0}/LICENSE +0 -0
  49. {ipygraph-1.14.2 → ipygraph-1.15.0}/NOTICE +0 -0
  50. {ipygraph-1.14.2 → ipygraph-1.15.0}/RELEASE.md +0 -0
  51. {ipygraph-1.14.2 → ipygraph-1.15.0}/babel.config.js +0 -0
  52. {ipygraph-1.14.2 → ipygraph-1.15.0}/install.json +0 -0
  53. {ipygraph-1.14.2 → ipygraph-1.15.0}/jest.config.js +0 -0
  54. {ipygraph-1.14.2 → ipygraph-1.15.0}/jupyter-config/server-config/myextension.json +0 -0
  55. {ipygraph-1.14.2 → ipygraph-1.15.0}/myextension/__init__.py +0 -0
  56. {ipygraph-1.14.2 → ipygraph-1.15.0}/myextension/app.py +0 -0
  57. {ipygraph-1.14.2 → ipygraph-1.15.0}/myextension/contents_manager.py +0 -0
  58. {ipygraph-1.14.2 → ipygraph-1.15.0}/myextension/kaggle.py +0 -0
  59. {ipygraph-1.14.2 → ipygraph-1.15.0}/myextension/kernel_manager.py +0 -0
  60. {ipygraph-1.14.2 → ipygraph-1.15.0}/myextension/labextension/static/style.js +0 -0
  61. {ipygraph-1.14.2 → ipygraph-1.15.0}/setup.py +0 -0
  62. {ipygraph-1.14.2 → ipygraph-1.15.0}/src/__tests__/myextension.spec.ts +0 -0
  63. {ipygraph-1.14.2 → ipygraph-1.15.0}/src/agentActions.ts +0 -0
  64. {ipygraph-1.14.2 → ipygraph-1.15.0}/src/api.ts +0 -0
  65. {ipygraph-1.14.2 → ipygraph-1.15.0}/src/contract.ts +0 -0
  66. {ipygraph-1.14.2 → ipygraph-1.15.0}/src/datasetPicker.ts +0 -0
  67. {ipygraph-1.14.2 → ipygraph-1.15.0}/src/diagramView.ts +0 -0
  68. {ipygraph-1.14.2 → ipygraph-1.15.0}/src/document.ts +0 -0
  69. {ipygraph-1.14.2 → ipygraph-1.15.0}/src/dropUpload.ts +0 -0
  70. {ipygraph-1.14.2 → ipygraph-1.15.0}/src/mdRender.ts +0 -0
  71. {ipygraph-1.14.2 → ipygraph-1.15.0}/src/model.ts +0 -0
  72. {ipygraph-1.14.2 → ipygraph-1.15.0}/src/secretsPanel.ts +0 -0
  73. {ipygraph-1.14.2 → ipygraph-1.15.0}/style/index.css +0 -0
  74. {ipygraph-1.14.2 → ipygraph-1.15.0}/style/index.js +0 -0
  75. {ipygraph-1.14.2 → ipygraph-1.15.0}/test_backend.py +0 -0
  76. {ipygraph-1.14.2 → ipygraph-1.15.0}/tsconfig.json +0 -0
  77. {ipygraph-1.14.2 → ipygraph-1.15.0}/tsconfig.test.json +0 -0
@@ -0,0 +1,20 @@
1
+ {
2
+ "version": "0.0.1",
3
+ "configurations": [
4
+ {
5
+ "name": "jupyterlab",
6
+ "runtimeExecutable": "python3",
7
+ "runtimeArgs": [
8
+ "-m",
9
+ "jupyter",
10
+ "lab",
11
+ "--no-browser",
12
+ "--ServerApp.token=",
13
+ "--ServerApp.password=",
14
+ "--port=8901",
15
+ "--notebook-dir=/Users/muruga/Projects/ipycopilot/jupyterlab-ipygb"
16
+ ],
17
+ "port": 8901
18
+ }
19
+ ]
20
+ }
@@ -131,3 +131,82 @@ dmypy.json
131
131
  Untitled*.ipynb
132
132
  untitled*.ipygb
133
133
  *.ipygb
134
+
135
+ # --- mac-mini-sync: generated, secrets, heavy binaries (kept local, see SYNC-NOTES.md) ---
136
+ .DS_Store
137
+ # dependencies & build output
138
+ node_modules/
139
+ .venv*/
140
+ venv/
141
+ __pycache__/
142
+ *.pyc
143
+ *.egg-info/
144
+ dist/
145
+ build/
146
+ .build/
147
+ .next/
148
+ .gradle/
149
+ .kotlin/
150
+ DerivedData/
151
+ Pods/
152
+ xcuserdata/
153
+ target/
154
+ .cache/
155
+ .ipynb_checkpoints/
156
+ .pytest_cache/
157
+ .mypy_cache/
158
+ .ruff_cache/
159
+ .turbo/
160
+ .parcel-cache/
161
+ coverage/
162
+ # local state & secrets
163
+ .env
164
+ .env.*
165
+ !.env.example
166
+ !.env.sample
167
+ *.pem
168
+ *.key
169
+ *.p12
170
+ *.jks
171
+ *.keystore
172
+ credentials*.json
173
+ service-account*
174
+ token.json
175
+ local.properties
176
+ .jupyter_ystore.db*
177
+ .claude/settings.local.json
178
+ .claude/worktrees/
179
+ # heavy media & archives & model weights
180
+ *.mp4
181
+ *.mov
182
+ *.mkv
183
+ *.webm
184
+ *.avi
185
+ *.m4v
186
+ *.wav
187
+ *.mp3
188
+ *.m4a
189
+ *.aac
190
+ *.flac
191
+ *.ogg
192
+ *.aiff
193
+ *.zip
194
+ *.tar
195
+ *.gz
196
+ *.tgz
197
+ *.7z
198
+ *.rar
199
+ *.pt
200
+ *.pth
201
+ *.ckpt
202
+ *.safetensors
203
+ *.onnx
204
+ *.h5
205
+ *.gguf
206
+ *.npz
207
+ # project-specific
208
+ /demo/instagram/reel-ai-era.mp4
209
+ /demo/instagram/reel-day6-merge.mp4
210
+ /demo/instagram/reel-notebook-chaos.mp4
211
+ /demo/ipygraph-demo.mp4
212
+ /demo/ipygraph-story.mp4
@@ -0,0 +1,52 @@
1
+ {
2
+ "0cc729d6-c09c-4594-8930-44612bc72185": {
3
+ "version": "3.0.0.a0",
4
+ "created_at": "2026-08-23T15:08:06.652307+00:00",
5
+ "document_version": "2.0.0"
6
+ },
7
+ "dd3b6181-cbfd-4039-93eb-6db2b0246ebd": {
8
+ "version": "3.0.0.a0",
9
+ "created_at": "2026-08-23T15:23:43.147711+00:00",
10
+ "document_version": "2.0.0"
11
+ },
12
+ "f30795eb-41c1-4e74-8d3d-9a803ae9a738": {
13
+ "version": "3.0.0.a0",
14
+ "created_at": "2026-09-18T03:40:58.005128+00:00",
15
+ "document_version": "1.0.0"
16
+ },
17
+ "e3034965-30b6-49aa-98ef-9a85416a7f2e": {
18
+ "version": "3.0.0.a0",
19
+ "created_at": "2026-09-18T03:43:06.459902+00:00",
20
+ "document_version": "2.0.0"
21
+ },
22
+ "1c8cf52b-76a7-4375-add1-1cdafd83fe22": {
23
+ "version": "3.0.0.a0",
24
+ "created_at": "2026-09-18T04:28:13.453173+00:00",
25
+ "document_version": "2.0.0"
26
+ },
27
+ "9d8f9246-4d22-431b-8367-16433fcf4ef7": {
28
+ "version": "3.0.0.a0",
29
+ "created_at": "2026-09-18T09:49:32.246067+00:00",
30
+ "document_version": "2.0.0"
31
+ },
32
+ "37564caf-d4cd-4ad6-91ef-8126414c7141": {
33
+ "version": "3.0.0.a0",
34
+ "created_at": "2026-11-08T07:23:25.129911+00:00",
35
+ "document_version": "2.0.0"
36
+ },
37
+ "cb5a2cbf-7a0a-4537-b52c-5524a382d3dc": {
38
+ "version": "3.0.0.a0",
39
+ "created_at": "2026-11-08T07:24:30.394928+00:00",
40
+ "document_version": "2.0.0"
41
+ },
42
+ "fe954943-09f8-404f-906e-e38f4000fa1e": {
43
+ "version": "3.0.0.a0",
44
+ "created_at": "2026-11-08T07:50:42.939301+00:00",
45
+ "document_version": "1.0.0"
46
+ },
47
+ "08924082-5c22-47a5-9f0c-11c82a5e7fac": {
48
+ "version": "3.0.0.a0",
49
+ "created_at": "2026-11-08T07:50:16.946795+00:00",
50
+ "document_version": "1.0.0"
51
+ }
52
+ }
@@ -0,0 +1,13 @@
1
+ # Changelog
2
+
3
+ <!-- <START NEW CHANGELOG ENTRY> -->
4
+
5
+ ## 1.15.0
6
+
7
+ - **`.ipydag` — DAG of notebooks**: a second document type wiring whole `.ipynb` files into a pipeline. Data flows through saved artifact files (`IPYDAG_INPUTS` / `IPYDAG_OUTPUTS` injected per run); ◆ decision (if/else) and ⬡ case (switch) nodes route the flow from values in upstream JSON artifacts. Visual editor with inspector, per-node and full-DAG runs (server-side via nbclient), and a DAG-node header inside member notebooks for editing inputs/outputs in place.
8
+ - **🧪 Experiments view**: a live leaderboard + journal of the whole experiment campaign, derived from the graph — metrics parsed from branch annotations and run outputs, Δ vs the baseline branch, winner highlighted, one-click markdown report.
9
+ - **Streaming agent**: new `/ipygb/agent/stream` endpoint streams model tokens into the panel as they generate (Anthropic, OpenAI, Gemini, claude CLI/SDK); Anthropic prompt caching and a bigger token budget cut turn latency; light sub-agent roles auto-route to fast models; Stop now cancels mid-request.
10
+ - **Agentic UX**: pinned live mission-plan checklist, per-step activity timelines with per-action status/duration and expandable full observations, live sub-agent task rows (branch workers, design debate, Kaggle, validators).
11
+ - Modernized assistant panel UI; verified against JupyterLab 4.6.6.
12
+
13
+ <!-- <END NEW CHANGELOG ENTRY> -->
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: ipygraph
3
- Version: 1.14.2
3
+ Version: 1.15.0
4
4
  Summary: Turn your Jupyter notebook into a branching graph of experiments — a JupyterLab extension with a plan-first AI copilot, branch isolation, and fork-join parallel execution.
5
5
  Project-URL: Homepage, https://github.com/jupyterlab/jupyterlab-ipygb
6
6
  Project-URL: Bug Tracker, https://github.com/jupyterlab/jupyterlab-ipygb/issues
@@ -222,6 +222,8 @@ Requires-Python: >=3.10
222
222
  Requires-Dist: dill
223
223
  Requires-Dist: jupyter-server<3,>=2.0.0
224
224
  Requires-Dist: jupyterlab<5,>=4.0.0
225
+ Requires-Dist: nbclient>=0.7
226
+ Requires-Dist: nbformat>=5.7
225
227
  Description-Content-Type: text/markdown
226
228
 
227
229
  <p align="center">
@@ -275,6 +277,27 @@ ipyGraph adds a new document type: **`.ipygb`** (a "Graphbook"). It's a **supers
275
277
 
276
278
  Because it's a superset, the kernel, outputs, and rendering are ordinary Jupyter — nothing proprietary about how your code runs. And you can always **export any root→tip path back to a plain `.ipynb`** to share or hand off.
277
279
 
280
+ ## The `.ipydag` file format
281
+
282
+ Where `.ipygb` branches **cells inside one notebook**, **`.ipydag`** wires **whole notebooks** into a DAG. Each node is an ordinary `.ipynb`; data moves between them through **saved files** ("artifacts") — an upstream notebook writes a file, the downstream notebook reloads it, and the files that support the flow stay on disk next to the `.ipydag`.
283
+
284
+ Three node types:
285
+
286
+ - **📓 Notebook** — runs a `.ipynb`. It declares the artifact files it *writes* (outputs) and the upstream artifacts it *reads* (inputs). When the DAG runs, an injected parameters cell (tagged `ipydag-parameters`) defines `IPYDAG_INPUTS` / `IPYDAG_OUTPUTS` dicts mapping those names to file paths — so notebooks stay ordinary, runnable Jupyter:
287
+
288
+ ```python
289
+ import json, pandas as pd
290
+ df = pd.read_parquet(IPYDAG_INPUTS["raw"])
291
+ ...
292
+ df.to_parquet(IPYDAG_OUTPUTS["clean"])
293
+ json.dump({"use_nn": True}, open(IPYDAG_OUTPUTS["flags"], "w"))
294
+ ```
295
+
296
+ - **◆ Decision (if/else)** — reads a **boolean** key out of a JSON artifact written upstream and routes the flow along its `true` or `false` edge.
297
+ - **⬡ Case (switch)** — reads a **string** the same way and takes the edge whose label matches (or the `default` edge).
298
+
299
+ The file itself is plain JSON (`nodes` + `edges` + layout). A run walks the DAG in topological order, executes each notebook server-side (saving its outputs in place — the executed notebooks are the run's record), evaluates gates, and **skips everything reachable only through untaken branches**. Create one from the Launcher ("Notebook DAG") or try the demo at `demo/dag/pipeline.ipydag`.
300
+
278
301
  ---
279
302
 
280
303
  ## See it
@@ -49,6 +49,27 @@ ipyGraph adds a new document type: **`.ipygb`** (a "Graphbook"). It's a **supers
49
49
 
50
50
  Because it's a superset, the kernel, outputs, and rendering are ordinary Jupyter — nothing proprietary about how your code runs. And you can always **export any root→tip path back to a plain `.ipynb`** to share or hand off.
51
51
 
52
+ ## The `.ipydag` file format
53
+
54
+ Where `.ipygb` branches **cells inside one notebook**, **`.ipydag`** wires **whole notebooks** into a DAG. Each node is an ordinary `.ipynb`; data moves between them through **saved files** ("artifacts") — an upstream notebook writes a file, the downstream notebook reloads it, and the files that support the flow stay on disk next to the `.ipydag`.
55
+
56
+ Three node types:
57
+
58
+ - **📓 Notebook** — runs a `.ipynb`. It declares the artifact files it *writes* (outputs) and the upstream artifacts it *reads* (inputs). When the DAG runs, an injected parameters cell (tagged `ipydag-parameters`) defines `IPYDAG_INPUTS` / `IPYDAG_OUTPUTS` dicts mapping those names to file paths — so notebooks stay ordinary, runnable Jupyter:
59
+
60
+ ```python
61
+ import json, pandas as pd
62
+ df = pd.read_parquet(IPYDAG_INPUTS["raw"])
63
+ ...
64
+ df.to_parquet(IPYDAG_OUTPUTS["clean"])
65
+ json.dump({"use_nn": True}, open(IPYDAG_OUTPUTS["flags"], "w"))
66
+ ```
67
+
68
+ - **◆ Decision (if/else)** — reads a **boolean** key out of a JSON artifact written upstream and routes the flow along its `true` or `false` edge.
69
+ - **⬡ Case (switch)** — reads a **string** the same way and takes the edge whose label matches (or the `default` edge).
70
+
71
+ The file itself is plain JSON (`nodes` + `edges` + layout). A run walks the DAG in topological order, executes each notebook server-side (saving its outputs in place — the executed notebooks are the run's record), evaluates gates, and **skips everything reachable only through untaken branches**. Create one from the Launcher ("Notebook DAG") or try the demo at `demo/dag/pipeline.ipydag`.
72
+
52
73
  ---
53
74
 
54
75
  ## See it
@@ -0,0 +1,7 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <testsuites name="jest tests" tests="1" failures="0" errors="0" time="1.16">
3
+ <testsuite name="myextension" errors="0" failures="0" skipped="0" timestamp="2026-11-08T07:40:34" time="0.722" tests="1">
4
+ <testcase classname="myextension should be tested" name="myextension should be tested" time="0.001">
5
+ </testcase>
6
+ </testsuite>
7
+ </testsuites>
@@ -1,4 +1,4 @@
1
1
  # This file is auto-generated by Hatchling. As such, do not:
2
2
  # - modify
3
3
  # - track in version control e.g. be sure to add to .gitignore
4
- __version__ = VERSION = '1.14.2'
4
+ __version__ = VERSION = '1.15.0'
@@ -81,10 +81,18 @@ ALWAYS respond with a single JSON object, no prose outside it:
81
81
 
82
82
  {
83
83
  "reply": "<natural-language explanation + your assumptions>",
84
+ "plan": [ {"step": "<= 8 words>", "status": "pending"|"doing"|"done"} ],
84
85
  "actions": [ <zero or more action objects, empty if you are asking> ],
85
86
  "questions": [ {"question": "<short question>", "options": ["<opt>", ...]} ]
86
87
  }
87
88
 
89
+ "plan" is your LIVE MISSION CHECKLIST, shown pinned in the panel. Include it \
90
+ in EVERY turn of a multi-step mission: 3-8 steps covering the WHOLE mission \
91
+ (not just this turn), each with its current status — keep step wording \
92
+ STABLE across turns and only flip statuses as work progresses, so the user \
93
+ watches the same list fill in. Put the step you are doing RIGHT NOW as \
94
+ "doing". Omit "plan" only for trivial single-action asks.
95
+
88
96
  "questions" is optional; include it only when you genuinely need input. When \
89
97
  you ask questions, keep "actions" empty and wait for the user's answer.
90
98
 
@@ -344,8 +352,11 @@ sklearn/GBM branches; run heavy neural-net training SEQUENTIALLY with \
344
352
  run_cells (parallel torch trainings starve each other and a kernel gets \
345
353
  killed mid-backward). Then in your reflection compare their metrics \
346
354
  against the current best. Keep the \
347
- winner as the new BEST; annotate every branch with set_comment (its metric + \
348
- a one-line takeaway) so the graph tells the story.
355
+ winner as the new BEST; annotate every branch with set_comment so the graph \
356
+ tells the story. FORMAT these annotations machine-readably — start with the \
357
+ metric, then the takeaway: "accuracy=0.913 — +embeddings beat the baseline \
358
+ by 2pts" — because the 🧪 Experiments view parses them into the live \
359
+ leaderboard (branch · metric · Δ vs baseline · winner) the user watches.
349
360
  5. ITERATE. Repeat BRANCH→EVALUATE from the new best, going DEEPER — e.g. \
350
361
  linear → MLP → +embeddings → +self-attention → multi-head → deeper/residual \
351
362
  — until the metric plateaus for two rounds or you reach a genuinely strong \
@@ -533,30 +544,62 @@ _FORMAT_RETRY_MSG = (
533
544
  )
534
545
 
535
546
 
547
+ def _prepare_turn(body: dict):
548
+ """Shared request parsing for the blocking and streaming handlers."""
549
+ provider = body.get("provider", "claude-cli")
550
+ role = body.get("role", "master")
551
+ # Light roles (dataset curation, Kaggle picking) run on the provider's
552
+ # fast model unless the caller pinned one — several-fold latency cut.
553
+ model = providers.pick_model(provider, body.get("model", ""), role)
554
+ messages = body.get("messages", [])
555
+ if not messages:
556
+ raise tornado.web.HTTPError(400, reason="No messages")
557
+
558
+ # Prepend the graph context to the latest user turn.
559
+ convo = [dict(m) for m in messages]
560
+ context = _graph_summary(body.get("graph", {}))
561
+ for m in reversed(convo):
562
+ if m.get("role") == "user":
563
+ m["content"] = f"Current graph:\n{context}\n\nRequest: {m['content']}"
564
+ break
565
+ return (
566
+ provider,
567
+ model,
568
+ role,
569
+ convo,
570
+ body.get("key", ""),
571
+ body.get("base_url", ""),
572
+ )
573
+
574
+
575
+ def _final_payload(parsed: dict, raw: str) -> dict:
576
+ """Normalize a parsed model reply into the response the frontend expects."""
577
+ actions = parsed.get("actions", [])
578
+ if not isinstance(actions, list):
579
+ actions = []
580
+ questions = parsed.get("questions", [])
581
+ if not isinstance(questions, list):
582
+ questions = []
583
+ plan = parsed.get("plan", [])
584
+ if not isinstance(plan, list):
585
+ plan = []
586
+ return {
587
+ "reply": parsed.get("reply", ""),
588
+ "plan": plan,
589
+ "actions": actions,
590
+ "questions": questions,
591
+ "done": bool(parsed.get("done", False)),
592
+ "raw": raw,
593
+ }
594
+
595
+
536
596
  class AgentHandler(APIHandler):
537
597
  """POST /ipygb/agent — run one assistant turn."""
538
598
 
539
599
  @tornado.web.authenticated
540
600
  async def post(self):
541
601
  body = self.get_json_body() or {}
542
- provider = body.get("provider", "claude-cli")
543
- model = body.get("model", "")
544
- messages = body.get("messages", [])
545
- graph = body.get("graph", {})
546
- key = body.get("key", "")
547
- base_url = body.get("base_url", "")
548
- role = body.get("role", "master")
549
-
550
- if not messages:
551
- raise tornado.web.HTTPError(400, reason="No messages")
552
-
553
- # Prepend the graph context to the latest user turn.
554
- convo = [dict(m) for m in messages]
555
- context = _graph_summary(graph)
556
- for m in reversed(convo):
557
- if m.get("role") == "user":
558
- m["content"] = f"Current graph:\n{context}\n\nRequest: {m['content']}"
559
- break
602
+ provider, model, role, convo, key, base_url = _prepare_turn(body)
560
603
 
561
604
  try:
562
605
  raw = await tornado.ioloop.IOLoop.current().run_in_executor(
@@ -594,24 +637,98 @@ class AgentHandler(APIHandler):
594
637
  parsed, raw = parsed2, raw2
595
638
  except Exception:
596
639
  self.log.exception("ipygb agent: format retry failed")
597
- reply = parsed.get("reply", "")
598
- actions = parsed.get("actions", [])
599
- if not isinstance(actions, list):
600
- actions = []
601
- questions = parsed.get("questions", [])
602
- if not isinstance(questions, list):
603
- questions = []
604
- self.finish(
605
- json.dumps(
606
- {
607
- "reply": reply,
608
- "actions": actions,
609
- "questions": questions,
610
- "done": bool(parsed.get("done", False)),
611
- "raw": raw,
612
- }
613
- )
614
- )
640
+ self.finish(json.dumps(_final_payload(parsed, raw)))
641
+
642
+
643
+ class AgentStreamHandler(APIHandler):
644
+ """
645
+ POST /ipygb/agent/stream — one assistant turn, streamed.
646
+
647
+ The response is a server-sent-event style chunked body; each event is a
648
+ line `data: {...}` followed by a blank line:
649
+ {"delta": "<text>"} model tokens as they are generated
650
+ {"note": "<text>"} progress notes (e.g. a format retry)
651
+ {"result": {...}} the final parsed turn (same shape as /agent)
652
+ {"error": "<text>"} fatal failure (no result follows)
653
+ """
654
+
655
+ @tornado.web.authenticated
656
+ async def post(self):
657
+ import asyncio
658
+
659
+ body = self.get_json_body() or {}
660
+ provider, model, role, convo, key, base_url = _prepare_turn(body)
661
+
662
+ self.set_header("Content-Type", "text/event-stream")
663
+ self.set_header("Cache-Control", "no-cache")
664
+ self.set_header("X-Accel-Buffering", "no")
665
+
666
+ loop = tornado.ioloop.IOLoop.current()
667
+ q: asyncio.Queue = asyncio.Queue()
668
+
669
+ def produce():
670
+ try:
671
+ for chunk in providers.stream_generate(
672
+ provider, model, get_prompt(role), convo, key, base_url
673
+ ):
674
+ loop.add_callback(q.put_nowait, ("delta", chunk))
675
+ loop.add_callback(q.put_nowait, ("end", None))
676
+ except Exception as e: # surfaced as an SSE error event
677
+ loop.add_callback(q.put_nowait, ("error", str(e)))
678
+
679
+ loop.run_in_executor(None, produce)
680
+
681
+ async def emit(obj) -> bool:
682
+ try:
683
+ self.write(f"data: {json.dumps(obj)}\n\n")
684
+ await self.flush()
685
+ return True
686
+ except Exception:
687
+ return False # client went away — drain quietly
688
+
689
+ chunks = []
690
+ failed = None
691
+ while True:
692
+ kind, payload = await q.get()
693
+ if kind == "delta":
694
+ chunks.append(payload)
695
+ await emit({"delta": payload})
696
+ elif kind == "error":
697
+ failed = payload
698
+ break
699
+ else:
700
+ break
701
+
702
+ raw = "".join(chunks)
703
+ if failed is not None and not raw:
704
+ self.log.error("ipygb agent stream failed: %s", failed)
705
+ await emit({"error": failed})
706
+ self.finish()
707
+ return
708
+
709
+ parsed, parsed_ok = _extract_json(raw)
710
+ if not parsed_ok:
711
+ # Same one-shot format correction as the blocking endpoint.
712
+ await emit({"note": "Reply was not valid JSON — retrying once…"})
713
+ retry_convo = convo + [
714
+ {"role": "assistant", "content": raw[-4000:]},
715
+ {"role": "user", "content": _FORMAT_RETRY_MSG},
716
+ ]
717
+ try:
718
+ raw2 = await loop.run_in_executor(
719
+ None,
720
+ lambda: providers.generate(
721
+ provider, model, get_prompt(role), retry_convo,
722
+ key, base_url
723
+ ),
724
+ )
725
+ parsed2, ok2 = _extract_json(raw2)
726
+ if ok2:
727
+ parsed, raw = parsed2, raw2
728
+ except Exception:
729
+ self.log.exception("ipygb agent: stream format retry failed")
730
+ await emit({"result": _final_payload(parsed, raw)})
731
+ self.finish()
615
732
 
616
733
 
617
734
  class AgentProvidersHandler(APIHandler):
@@ -692,6 +809,10 @@ def setup_agent_handlers(web_app):
692
809
  host_pattern,
693
810
  [
694
811
  (url_path_join(base_url, "ipygb", "agent"), AgentHandler),
812
+ (
813
+ url_path_join(base_url, "ipygb", "agent", "stream"),
814
+ AgentStreamHandler,
815
+ ),
695
816
  (
696
817
  url_path_join(base_url, "ipygb", "agent", "providers"),
697
818
  AgentProvidersHandler,