roscoe 2.3.0__tar.gz → 2.4.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.
- {roscoe-2.3.0/roscoe.egg-info → roscoe-2.4.0}/PKG-INFO +84 -2
- {roscoe-2.3.0 → roscoe-2.4.0}/README.md +83 -1
- {roscoe-2.3.0 → roscoe-2.4.0}/pyproject.toml +2 -1
- roscoe-2.4.0/roscoe/__init__.py +18 -0
- roscoe-2.4.0/roscoe/cli/build_command.py +406 -0
- roscoe-2.4.0/roscoe/cli/build_ui.py +683 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/cli/eval_gui.py +194 -194
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/cli/google_auth_command.py +99 -99
- roscoe-2.4.0/roscoe/cli/graph_command.py +126 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/cli/gui_theme.py +238 -238
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/cli/init_command.py +49 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/cli/main.py +20 -1
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/cli/monitor_gui.py +199 -199
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/cli/pricing_command.py +42 -42
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/cli/pricing_gui.py +147 -147
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/cli/run_command.py +295 -236
- roscoe-2.4.0/roscoe/cli/run_web.py +504 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/cli/scaffold/agent_config.yaml +37 -36
- roscoe-2.4.0/roscoe/cli/scaffold/custom_ui_example.py +98 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/cli/scaffold/db.py +107 -107
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/cli/scaffold/docs.md +7 -0
- roscoe-2.4.0/roscoe/cli/scaffold_nc/agent_config.yaml +71 -0
- roscoe-2.4.0/roscoe/cli/scaffold_nc/workflow.yaml +46 -0
- roscoe-2.4.0/roscoe/cli/validate_command.py +134 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/config/loader.py +19 -13
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/connectors/__init__.py +6 -1
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/connectors/_graph_base.py +6 -2
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/connectors/base_connector.py +22 -1
- roscoe-2.4.0/roscoe/connectors/database.py +259 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/connectors/google_workspace.py +52 -8
- roscoe-2.4.0/roscoe/connectors/ticktick.py +114 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/llm/provider_factory.py +30 -15
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/middleware/retry.py +123 -123
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/monitoring/web.py +194 -194
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/pricing.py +63 -63
- roscoe-2.4.0/roscoe/workflow/__init__.py +65 -0
- roscoe-2.4.0/roscoe/workflow/diagram.py +143 -0
- roscoe-2.4.0/roscoe/workflow/executor.py +585 -0
- roscoe-2.4.0/roscoe/workflow/expressions.py +403 -0
- roscoe-2.4.0/roscoe/workflow/loader.py +97 -0
- roscoe-2.4.0/roscoe/workflow/registry.py +94 -0
- roscoe-2.4.0/roscoe/workflow/runner.py +282 -0
- roscoe-2.4.0/roscoe/workflow/schema.py +391 -0
- roscoe-2.4.0/roscoe/workflow/validate.py +301 -0
- {roscoe-2.3.0 → roscoe-2.4.0/roscoe.egg-info}/PKG-INFO +84 -2
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe.egg-info/SOURCES.txt +18 -0
- roscoe-2.3.0/roscoe/__init__.py +0 -8
- roscoe-2.3.0/roscoe/cli/run_web.py +0 -299
- {roscoe-2.3.0 → roscoe-2.4.0}/LICENSE +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/approval/__init__.py +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/approval/gate.py +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/cli/__init__.py +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/cli/eval_command.py +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/cli/monitor_command.py +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/cli/scaffold/.env.example +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/cli/scaffold/evals/test_cases.json +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/cli/scaffold/main.py +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/cli/scaffold/prompts/system.txt +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/cli/scaffold/tools/my_tools.py +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/cli/wizard_gui.py +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/config/__init__.py +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/connectors/github.py +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/connectors/jira.py +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/connectors/notion.py +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/connectors/outlook.py +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/connectors/rest_api.py +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/connectors/servicenow.py +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/connectors/sharepoint.py +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/connectors/snowflake.py +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/core/__init__.py +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/core/agent_base.py +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/core/agent_result.py +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/core/agent_runner.py +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/core/executor.py +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/core/state.py +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/evals/__init__.py +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/evals/dataset.py +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/evals/eval_runner.py +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/evals/regression.py +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/evals/report.py +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/evals/scorers/__init__.py +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/evals/scorers/_judge.py +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/evals/scorers/base.py +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/evals/scorers/hallucination.py +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/evals/scorers/output_quality.py +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/evals/scorers/tool_usage.py +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/llm/__init__.py +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/llm/base_provider.py +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/llm/capability_map.py +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/memory/__init__.py +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/memory/conversation.py +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/memory/knowledge.py +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/memory/persistent.py +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/middleware/__init__.py +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/middleware/audit_logger.py +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/middleware/cost_tracker.py +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/middleware/rate_limiter.py +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/monitoring/__init__.py +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/monitoring/alerts.py +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/monitoring/dashboard.py +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/monitoring/exporters/__init__.py +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/monitoring/exporters/azure_monitor.py +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/monitoring/exporters/prometheus.py +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/monitoring/metrics.py +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/monitoring/notifier.py +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/templates/__init__.py +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/templates/exec_assistant_agent/__init__.py +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/templates/exec_assistant_agent/agent_config.yaml +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/templates/exec_assistant_agent/prompts/system.txt +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/templates/exec_assistant_agent/tools/__init__.py +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/templates/exec_assistant_agent/tools/exec_tools.py +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/templates/google_workspace_agent/agent_config.yaml +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/templates/google_workspace_agent/prompts/system.txt +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/templates/google_workspace_agent/tools/__init__.py +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/templates/google_workspace_agent/tools/gws_tools.py +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/templates/hr_agent/__init__.py +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/templates/hr_agent/agent_config.yaml +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/templates/hr_agent/prompts/system.txt +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/templates/hr_agent/tools/__init__.py +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/templates/hr_agent/tools/hr_tools.py +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/templates/it_support_agent/__init__.py +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/templates/it_support_agent/agent_config.yaml +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/templates/it_support_agent/prompts/system.txt +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/templates/it_support_agent/tools/__init__.py +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/templates/it_support_agent/tools/it_tools.py +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/templates/knowledge_base_agent/__init__.py +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/templates/knowledge_base_agent/agent_config.yaml +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/templates/knowledge_base_agent/prompts/system.txt +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/templates/knowledge_base_agent/tools/__init__.py +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/templates/knowledge_base_agent/tools/kb_tools.py +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/templates/legal_agent/__init__.py +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/templates/legal_agent/agent_config.yaml +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/templates/legal_agent/prompts/system.txt +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/templates/legal_agent/tools/__init__.py +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/templates/legal_agent/tools/legal_tools.py +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/tools/__init__.py +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe/tools/decorator.py +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe.egg-info/dependency_links.txt +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe.egg-info/entry_points.txt +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe.egg-info/requires.txt +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/roscoe.egg-info/top_level.txt +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/setup.cfg +0 -0
- {roscoe-2.3.0 → roscoe-2.4.0}/tests/test_smoke.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: roscoe
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.4.0
|
|
4
4
|
Summary: roscoe — Ready-to-run Orchestration SDK: Configurable, Observable, Extensible.
|
|
5
5
|
Author-email: rhealaloo <rhealaloo@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -216,7 +216,9 @@ agent = AgentRunner.from_config("agent.yaml", tools=[my_tool] + gh.tools)
|
|
|
216
216
|
| **SharePoint** | list files, download, upload, search | MS Graph (OAuth2) |
|
|
217
217
|
| **GitHub** | list repos, issues, PRs, create issue | Personal access token |
|
|
218
218
|
| **Notion** | search, pages, databases, blocks | Integration token |
|
|
219
|
-
| **Google Workspace** | Gmail send/read, Calendar, Tasks, Drive search | Service account or OAuth2 (`roscoe google-auth`) |
|
|
219
|
+
| **Google Workspace** | Gmail send/read, Calendar, Tasks, Drive search + file read | Service account or OAuth2 (`roscoe google-auth`) |
|
|
220
|
+
| **TickTick** | list projects, create/get/complete tasks | OAuth2 access token |
|
|
221
|
+
| **Database** | query, execute, list/describe tables | SQLite built in (`schema:` builds it on first use); any DB-API driver by name |
|
|
220
222
|
| **Snowflake** | execute SQL queries | `pip install roscoe[snowflake]` |
|
|
221
223
|
|
|
222
224
|
### Human-in-the-loop
|
|
@@ -343,6 +345,69 @@ roscoe init my-gws --template google_workspace_agent
|
|
|
343
345
|
|
|
344
346
|
---
|
|
345
347
|
|
|
348
|
+
## Workflows — no-code agents
|
|
349
|
+
|
|
350
|
+
`agent_config.yaml` configures the scaffolding; a **workflow** defines the behaviour,
|
|
351
|
+
so the common cases stop costing a Python file each:
|
|
352
|
+
|
|
353
|
+
```bash
|
|
354
|
+
roscoe init-nc my-agent # agent_config.yaml + workflow.yaml, no Python
|
|
355
|
+
roscoe validate # check it before running it
|
|
356
|
+
roscoe run --set topic="cloud security"
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
```yaml
|
|
360
|
+
workflow:
|
|
361
|
+
entry: lookup
|
|
362
|
+
nodes:
|
|
363
|
+
- id: lookup
|
|
364
|
+
type: connector_action # call a connector method — config, not code
|
|
365
|
+
connector: hr_api
|
|
366
|
+
method: get_employee
|
|
367
|
+
inputs: { employee_id: "{{ input.id }}" }
|
|
368
|
+
output: employee
|
|
369
|
+
|
|
370
|
+
- id: eligible
|
|
371
|
+
type: condition # branch on the state
|
|
372
|
+
when: "employee.department in ['Engineering', 'Product']"
|
|
373
|
+
then: grant
|
|
374
|
+
else: explain
|
|
375
|
+
|
|
376
|
+
- id: grant
|
|
377
|
+
type: connector_action
|
|
378
|
+
requires_approval: true # pause for a human before it runs
|
|
379
|
+
connector: vpn
|
|
380
|
+
method: grant_access
|
|
381
|
+
inputs: { employee_id: "{{ employee.id }}" }
|
|
382
|
+
next: END
|
|
383
|
+
|
|
384
|
+
- id: explain
|
|
385
|
+
type: llm_step # one prompt, no tools
|
|
386
|
+
prompt: "Explain why {{ employee.name }} was denied."
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
| Node type | Does |
|
|
390
|
+
|---|---|
|
|
391
|
+
| `connector_action` | Calls one connector method with templated arguments |
|
|
392
|
+
| `condition` | Branches on a sandboxed expression over the shared state |
|
|
393
|
+
| `llm_step` | Sends one prompt to the model |
|
|
394
|
+
| `agent_step` | Hands a task to a named agent running the autonomous ReAct loop |
|
|
395
|
+
|
|
396
|
+
`agent_step` is the escape hatch — when a stretch of work is too open-ended to wire
|
|
397
|
+
by hand, let an agent pick its own tool calls. Several `agent_step` nodes passing
|
|
398
|
+
results through the state is how multi-agent works, with the graph as the
|
|
399
|
+
orchestrator. Agents inherit the workflow's approval gate, so wrapping work in an
|
|
400
|
+
agent is never a way around one.
|
|
401
|
+
|
|
402
|
+
Expressions are parsed and walked against an allowlist, never `eval()`'d: dotted
|
|
403
|
+
access is dict lookup rather than `getattr`, and only allowlisted helpers can be
|
|
404
|
+
called. Full reference: [`docs/WORKFLOW_SPEC.md`](docs/WORKFLOW_SPEC.md).
|
|
405
|
+
|
|
406
|
+
Projects that write their tools in Python are unaffected — `roscoe init` and the
|
|
407
|
+
existing `@tool` flow work exactly as before.
|
|
408
|
+
|
|
409
|
+
---
|
|
410
|
+
|
|
346
411
|
## Architecture
|
|
347
412
|
|
|
348
413
|
roscoe runs its own async ReAct loop (no LangGraph dependency). The loop is ~100 lines
|
|
@@ -371,7 +436,17 @@ roscoe init <name> # scaffold with GUI wizard
|
|
|
371
436
|
roscoe init <name> --quick # scaffold with defaults (no wizard)
|
|
372
437
|
roscoe init <name> --cli # scaffold with terminal wizard
|
|
373
438
|
roscoe init <name> --template <t> # scaffold from a template
|
|
439
|
+
roscoe init-nc <name> # no-code project — behaviour in workflow.yaml
|
|
374
440
|
|
|
441
|
+
roscoe validate # check a workflow before running it
|
|
442
|
+
roscoe validate --workflow flow.yaml # check a specific workflow file
|
|
443
|
+
|
|
444
|
+
roscoe graph # see the workflow as a diagram
|
|
445
|
+
roscoe graph --terminal # print Mermaid source instead
|
|
446
|
+
|
|
447
|
+
roscoe build # visual editor — drag, connect, save
|
|
448
|
+
|
|
449
|
+
roscoe run --set topic="cloud security" # run a workflow with inputs
|
|
375
450
|
roscoe run # browser chat (default)
|
|
376
451
|
roscoe run --terminal # interactive chat in the terminal, streamed
|
|
377
452
|
roscoe run -m "message" # one-shot message, terminal, exits after
|
|
@@ -400,6 +475,13 @@ UI entry point — `app.py`, or whatever `ui_script:` names in `agent_config.yam
|
|
|
400
475
|
login/dashboard/chat app just works with no flags. `--terminal` and `-m` always
|
|
401
476
|
bypass any custom UI script and talk to the agent directly in-process.
|
|
402
477
|
|
|
478
|
+
Every scaffolded project ships `custom_ui_example.py` — a minimal Flask app showing
|
|
479
|
+
how to build one: it constructs the same `WorkflowRunner`/`AgentRunner` `roscoe run`
|
|
480
|
+
uses internally and calls `.run()` / `.resume()` on it, so approval pauses, cost
|
|
481
|
+
tracking, and audit logging all come for free. Rename it to `app.py` (or point
|
|
482
|
+
`ui_script:` at it) to switch it on; swap Flask for whatever framework you prefer —
|
|
483
|
+
the runner calls are the part worth keeping.
|
|
484
|
+
|
|
403
485
|
---
|
|
404
486
|
|
|
405
487
|
## Install
|
|
@@ -179,7 +179,9 @@ agent = AgentRunner.from_config("agent.yaml", tools=[my_tool] + gh.tools)
|
|
|
179
179
|
| **SharePoint** | list files, download, upload, search | MS Graph (OAuth2) |
|
|
180
180
|
| **GitHub** | list repos, issues, PRs, create issue | Personal access token |
|
|
181
181
|
| **Notion** | search, pages, databases, blocks | Integration token |
|
|
182
|
-
| **Google Workspace** | Gmail send/read, Calendar, Tasks, Drive search | Service account or OAuth2 (`roscoe google-auth`) |
|
|
182
|
+
| **Google Workspace** | Gmail send/read, Calendar, Tasks, Drive search + file read | Service account or OAuth2 (`roscoe google-auth`) |
|
|
183
|
+
| **TickTick** | list projects, create/get/complete tasks | OAuth2 access token |
|
|
184
|
+
| **Database** | query, execute, list/describe tables | SQLite built in (`schema:` builds it on first use); any DB-API driver by name |
|
|
183
185
|
| **Snowflake** | execute SQL queries | `pip install roscoe[snowflake]` |
|
|
184
186
|
|
|
185
187
|
### Human-in-the-loop
|
|
@@ -306,6 +308,69 @@ roscoe init my-gws --template google_workspace_agent
|
|
|
306
308
|
|
|
307
309
|
---
|
|
308
310
|
|
|
311
|
+
## Workflows — no-code agents
|
|
312
|
+
|
|
313
|
+
`agent_config.yaml` configures the scaffolding; a **workflow** defines the behaviour,
|
|
314
|
+
so the common cases stop costing a Python file each:
|
|
315
|
+
|
|
316
|
+
```bash
|
|
317
|
+
roscoe init-nc my-agent # agent_config.yaml + workflow.yaml, no Python
|
|
318
|
+
roscoe validate # check it before running it
|
|
319
|
+
roscoe run --set topic="cloud security"
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
```yaml
|
|
323
|
+
workflow:
|
|
324
|
+
entry: lookup
|
|
325
|
+
nodes:
|
|
326
|
+
- id: lookup
|
|
327
|
+
type: connector_action # call a connector method — config, not code
|
|
328
|
+
connector: hr_api
|
|
329
|
+
method: get_employee
|
|
330
|
+
inputs: { employee_id: "{{ input.id }}" }
|
|
331
|
+
output: employee
|
|
332
|
+
|
|
333
|
+
- id: eligible
|
|
334
|
+
type: condition # branch on the state
|
|
335
|
+
when: "employee.department in ['Engineering', 'Product']"
|
|
336
|
+
then: grant
|
|
337
|
+
else: explain
|
|
338
|
+
|
|
339
|
+
- id: grant
|
|
340
|
+
type: connector_action
|
|
341
|
+
requires_approval: true # pause for a human before it runs
|
|
342
|
+
connector: vpn
|
|
343
|
+
method: grant_access
|
|
344
|
+
inputs: { employee_id: "{{ employee.id }}" }
|
|
345
|
+
next: END
|
|
346
|
+
|
|
347
|
+
- id: explain
|
|
348
|
+
type: llm_step # one prompt, no tools
|
|
349
|
+
prompt: "Explain why {{ employee.name }} was denied."
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
| Node type | Does |
|
|
353
|
+
|---|---|
|
|
354
|
+
| `connector_action` | Calls one connector method with templated arguments |
|
|
355
|
+
| `condition` | Branches on a sandboxed expression over the shared state |
|
|
356
|
+
| `llm_step` | Sends one prompt to the model |
|
|
357
|
+
| `agent_step` | Hands a task to a named agent running the autonomous ReAct loop |
|
|
358
|
+
|
|
359
|
+
`agent_step` is the escape hatch — when a stretch of work is too open-ended to wire
|
|
360
|
+
by hand, let an agent pick its own tool calls. Several `agent_step` nodes passing
|
|
361
|
+
results through the state is how multi-agent works, with the graph as the
|
|
362
|
+
orchestrator. Agents inherit the workflow's approval gate, so wrapping work in an
|
|
363
|
+
agent is never a way around one.
|
|
364
|
+
|
|
365
|
+
Expressions are parsed and walked against an allowlist, never `eval()`'d: dotted
|
|
366
|
+
access is dict lookup rather than `getattr`, and only allowlisted helpers can be
|
|
367
|
+
called. Full reference: [`docs/WORKFLOW_SPEC.md`](docs/WORKFLOW_SPEC.md).
|
|
368
|
+
|
|
369
|
+
Projects that write their tools in Python are unaffected — `roscoe init` and the
|
|
370
|
+
existing `@tool` flow work exactly as before.
|
|
371
|
+
|
|
372
|
+
---
|
|
373
|
+
|
|
309
374
|
## Architecture
|
|
310
375
|
|
|
311
376
|
roscoe runs its own async ReAct loop (no LangGraph dependency). The loop is ~100 lines
|
|
@@ -334,7 +399,17 @@ roscoe init <name> # scaffold with GUI wizard
|
|
|
334
399
|
roscoe init <name> --quick # scaffold with defaults (no wizard)
|
|
335
400
|
roscoe init <name> --cli # scaffold with terminal wizard
|
|
336
401
|
roscoe init <name> --template <t> # scaffold from a template
|
|
402
|
+
roscoe init-nc <name> # no-code project — behaviour in workflow.yaml
|
|
337
403
|
|
|
404
|
+
roscoe validate # check a workflow before running it
|
|
405
|
+
roscoe validate --workflow flow.yaml # check a specific workflow file
|
|
406
|
+
|
|
407
|
+
roscoe graph # see the workflow as a diagram
|
|
408
|
+
roscoe graph --terminal # print Mermaid source instead
|
|
409
|
+
|
|
410
|
+
roscoe build # visual editor — drag, connect, save
|
|
411
|
+
|
|
412
|
+
roscoe run --set topic="cloud security" # run a workflow with inputs
|
|
338
413
|
roscoe run # browser chat (default)
|
|
339
414
|
roscoe run --terminal # interactive chat in the terminal, streamed
|
|
340
415
|
roscoe run -m "message" # one-shot message, terminal, exits after
|
|
@@ -363,6 +438,13 @@ UI entry point — `app.py`, or whatever `ui_script:` names in `agent_config.yam
|
|
|
363
438
|
login/dashboard/chat app just works with no flags. `--terminal` and `-m` always
|
|
364
439
|
bypass any custom UI script and talk to the agent directly in-process.
|
|
365
440
|
|
|
441
|
+
Every scaffolded project ships `custom_ui_example.py` — a minimal Flask app showing
|
|
442
|
+
how to build one: it constructs the same `WorkflowRunner`/`AgentRunner` `roscoe run`
|
|
443
|
+
uses internally and calls `.run()` / `.resume()` on it, so approval pauses, cost
|
|
444
|
+
tracking, and audit logging all come for free. Rename it to `app.py` (or point
|
|
445
|
+
`ui_script:` at it) to switch it on; swap Flask for whatever framework you prefer —
|
|
446
|
+
the runner calls are the part worth keeping.
|
|
447
|
+
|
|
366
448
|
---
|
|
367
449
|
|
|
368
450
|
## Install
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "roscoe"
|
|
7
|
-
version = "2.
|
|
7
|
+
version = "2.4.0"
|
|
8
8
|
description = "roscoe — Ready-to-run Orchestration SDK: Configurable, Observable, Extensible."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -61,6 +61,7 @@ roscoe = [
|
|
|
61
61
|
"templates/**/*.txt",
|
|
62
62
|
"cli/scaffold/**/*",
|
|
63
63
|
"cli/scaffold/.env.example",
|
|
64
|
+
"cli/scaffold_nc/**/*",
|
|
64
65
|
]
|
|
65
66
|
|
|
66
67
|
[tool.pytest.ini_options]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""roscoe — provider-agnostic LangChain agent framework with middleware and evals."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
# langchain_core imports transformers (for token counting) as soon as a chat
|
|
6
|
+
# model class is touched, and transformers imports torch unconditionally on
|
|
7
|
+
# import. roscoe never uses torch itself, but a broken local torch install
|
|
8
|
+
# (e.g. a missing DLL dependency) would otherwise crash every roscoe import.
|
|
9
|
+
# USE_TORCH=0 is transformers' own documented flag for skipping that import;
|
|
10
|
+
# setdefault so a project that genuinely wants torch can still opt back in.
|
|
11
|
+
os.environ.setdefault("USE_TORCH", "0")
|
|
12
|
+
|
|
13
|
+
from roscoe.core.agent_result import AgentResult
|
|
14
|
+
from roscoe.core.agent_runner import AgentRunner
|
|
15
|
+
|
|
16
|
+
__version__ = "2.4.0"
|
|
17
|
+
|
|
18
|
+
__all__ = ["AgentRunner", "AgentResult", "__version__"]
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
"""``roscoe build`` — a visual editor for workflows.
|
|
2
|
+
|
|
3
|
+
Two tabs. **Flow** is the canvas: drag nodes on, connect them, fill in a form per
|
|
4
|
+
node, and save — the project's ``workflow.yaml`` is written from the graph.
|
|
5
|
+
**Setup** edits the rest of the project: the model, connectors, agents and the
|
|
6
|
+
``ui:`` block, writing ``agent_config.yaml``. Together they cover everything a
|
|
7
|
+
declarative project needs, so a whole agent can be built without opening a YAML
|
|
8
|
+
file. Editing by hand still works — the editor loads whatever is on disk, so
|
|
9
|
+
neither way of working locks you out of the other.
|
|
10
|
+
|
|
11
|
+
Setup reads the config *raw*, never through :func:`load_config`: that resolves
|
|
12
|
+
``${VAR}`` against the environment, and writing the result back would bake real
|
|
13
|
+
credentials into a file the user is about to commit.
|
|
14
|
+
|
|
15
|
+
Node positions live in a sibling ``.workflow-layout.json`` rather than in the
|
|
16
|
+
workflow itself, so the config stays about behaviour and a hand-edited file never
|
|
17
|
+
fills up with pixel coordinates.
|
|
18
|
+
|
|
19
|
+
**Saving rewrites the file, so YAML comments in the ``workflow:`` block are lost.**
|
|
20
|
+
That is inherent to round-tripping through a graph; the command says so before it
|
|
21
|
+
overwrites anything.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
import webbrowser
|
|
28
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
import click
|
|
33
|
+
import yaml
|
|
34
|
+
|
|
35
|
+
from roscoe.config.loader import ConfigError, load_config
|
|
36
|
+
from roscoe.workflow.loader import DEFAULT_WORKFLOW_FILE, find_workflow_file
|
|
37
|
+
from roscoe.workflow.registry import available_types, build_connectors
|
|
38
|
+
from roscoe.workflow.schema import Workflow, WorkflowError
|
|
39
|
+
from roscoe.workflow.validate import validate_workflow
|
|
40
|
+
|
|
41
|
+
#: Where node positions are kept — beside the workflow, out of the config.
|
|
42
|
+
LAYOUT_FILE = ".workflow-layout.json"
|
|
43
|
+
|
|
44
|
+
#: Config keys the Setup tab owns. Everything else in the file — `agent_name`,
|
|
45
|
+
#: `middleware`, `memory` — is read and written back untouched.
|
|
46
|
+
_EDITABLE_CONFIG_KEYS = ("model", "connectors", "ui")
|
|
47
|
+
|
|
48
|
+
_CONFIG_HEADER = (
|
|
49
|
+
"# roscoe agent configuration. Edited by `roscoe build`, or by hand — the\n"
|
|
50
|
+
"# editor reloads whatever is here.\n"
|
|
51
|
+
"# ${VAR_NAME} resolves from your shell env or .env.\n\n"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
_WORKFLOW_HEADER = (
|
|
55
|
+
"# Generated by `roscoe build`. Editing by hand is fine — the editor\n"
|
|
56
|
+
"# reloads whatever is here.\n\n"
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@click.command("build")
|
|
61
|
+
@click.option("--config", default="agent_config.yaml", show_default=True,
|
|
62
|
+
help="Path to the agent config YAML.")
|
|
63
|
+
@click.option("--workflow", "workflow_path", default=None,
|
|
64
|
+
help="Workflow file to edit. Defaults to workflow.yaml beside the config.")
|
|
65
|
+
@click.option("--host", default="127.0.0.1", show_default=True, help="Editor host.")
|
|
66
|
+
@click.option("--port", default=8099, show_default=True, help="Editor port.")
|
|
67
|
+
@click.option("--no-browser", is_flag=True, help="Serve without opening a browser.")
|
|
68
|
+
def build_command(
|
|
69
|
+
config: str, workflow_path: str | None, host: str, port: int, no_browser: bool
|
|
70
|
+
) -> None:
|
|
71
|
+
"""Open the visual workflow editor."""
|
|
72
|
+
config_file = Path(config)
|
|
73
|
+
target = Path(workflow_path) if workflow_path else (
|
|
74
|
+
find_workflow_file(config_file) or config_file.parent / DEFAULT_WORKFLOW_FILE
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
state = _EditorState(config_file, target)
|
|
78
|
+
httpd = HTTPServer((host, port), _handler_for(state))
|
|
79
|
+
url = f"http://{host}:{port}"
|
|
80
|
+
|
|
81
|
+
click.secho(f"roscoe build — editing {target}", fg="blue", bold=True)
|
|
82
|
+
if target.is_file():
|
|
83
|
+
click.secho(" Saving rewrites this file; comments in it will be lost.", fg="yellow")
|
|
84
|
+
else:
|
|
85
|
+
click.secho(" New workflow — it will be created when you save.", dim=True)
|
|
86
|
+
click.secho(f" {url} (Ctrl-C to stop)", dim=True)
|
|
87
|
+
|
|
88
|
+
if not no_browser:
|
|
89
|
+
try:
|
|
90
|
+
webbrowser.open(url)
|
|
91
|
+
except Exception: # noqa: BLE001
|
|
92
|
+
pass
|
|
93
|
+
try:
|
|
94
|
+
httpd.serve_forever()
|
|
95
|
+
except KeyboardInterrupt:
|
|
96
|
+
click.echo("\nstopped.")
|
|
97
|
+
finally:
|
|
98
|
+
httpd.server_close()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class _EditorState:
|
|
102
|
+
"""Reads and writes the workflow file the editor is pointed at."""
|
|
103
|
+
|
|
104
|
+
def __init__(self, config_file: Path, workflow_file: Path) -> None:
|
|
105
|
+
self.config_file = config_file
|
|
106
|
+
self.workflow_file = workflow_file
|
|
107
|
+
self.layout_file = workflow_file.parent / LAYOUT_FILE
|
|
108
|
+
|
|
109
|
+
# --- loading ---
|
|
110
|
+
|
|
111
|
+
def load(self) -> dict[str, Any]:
|
|
112
|
+
"""Current workflow + saved positions + what connectors offer."""
|
|
113
|
+
block = self._read_block()
|
|
114
|
+
try:
|
|
115
|
+
workflow = Workflow.from_dict(block, self._agents()) if block else None
|
|
116
|
+
error = None
|
|
117
|
+
except WorkflowError as exc:
|
|
118
|
+
workflow, error = None, str(exc)
|
|
119
|
+
|
|
120
|
+
raw_config = self._raw_config()
|
|
121
|
+
return {
|
|
122
|
+
"file": str(self.workflow_file),
|
|
123
|
+
"config_file": str(self.config_file),
|
|
124
|
+
"workflow": workflow.to_dict() if workflow else _starter(),
|
|
125
|
+
"layout": self._read_layout(),
|
|
126
|
+
"methods": self._methods(),
|
|
127
|
+
"agents": sorted(self._agents()),
|
|
128
|
+
"error": error,
|
|
129
|
+
# Setup tab. Read raw, never through load_config — that resolves
|
|
130
|
+
# ${VAR} against the environment, and a save would then bake real
|
|
131
|
+
# secrets into a file the user commits.
|
|
132
|
+
"config": {key: raw_config.get(key) for key in _EDITABLE_CONFIG_KEYS},
|
|
133
|
+
"agents_detail": self._agents(),
|
|
134
|
+
"connector_types": available_types(),
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
def _raw_config(self) -> dict[str, Any]:
|
|
138
|
+
"""The config file exactly as written — placeholders left as placeholders."""
|
|
139
|
+
if not self.config_file.is_file():
|
|
140
|
+
return {}
|
|
141
|
+
try:
|
|
142
|
+
raw = yaml.safe_load(self.config_file.read_text(encoding="utf-8")) or {}
|
|
143
|
+
except yaml.YAMLError:
|
|
144
|
+
return {}
|
|
145
|
+
return raw if isinstance(raw, dict) else {}
|
|
146
|
+
|
|
147
|
+
def _read_block(self) -> dict[str, Any]:
|
|
148
|
+
"""The workflow mapping, from its own file or the config's `workflow:` key."""
|
|
149
|
+
if self.workflow_file.is_file():
|
|
150
|
+
raw = yaml.safe_load(self.workflow_file.read_text(encoding="utf-8")) or {}
|
|
151
|
+
return raw.get("workflow", raw)
|
|
152
|
+
if self.config_file.is_file():
|
|
153
|
+
try:
|
|
154
|
+
return (load_config(self.config_file) or {}).get("workflow") or {}
|
|
155
|
+
except ConfigError:
|
|
156
|
+
return {}
|
|
157
|
+
return {}
|
|
158
|
+
|
|
159
|
+
def _agents(self) -> dict[str, Any]:
|
|
160
|
+
for source in (self.workflow_file, self.config_file):
|
|
161
|
+
if not source.is_file():
|
|
162
|
+
continue
|
|
163
|
+
try:
|
|
164
|
+
raw = yaml.safe_load(source.read_text(encoding="utf-8")) or {}
|
|
165
|
+
except yaml.YAMLError:
|
|
166
|
+
continue
|
|
167
|
+
if raw.get("agents"):
|
|
168
|
+
return raw["agents"]
|
|
169
|
+
return {}
|
|
170
|
+
|
|
171
|
+
def _methods(self) -> dict[str, list[str]]:
|
|
172
|
+
"""Method names per connector, so the editor offers real choices."""
|
|
173
|
+
connectors = self._connectors()
|
|
174
|
+
if not connectors:
|
|
175
|
+
return {}
|
|
176
|
+
try:
|
|
177
|
+
return {
|
|
178
|
+
name: sorted(tool.name for tool in connector.tools)
|
|
179
|
+
for name, connector in connectors.items()
|
|
180
|
+
}
|
|
181
|
+
finally:
|
|
182
|
+
_close(connectors)
|
|
183
|
+
|
|
184
|
+
def _existing_document(self) -> dict[str, Any]:
|
|
185
|
+
"""The workflow file's other top-level keys, so a save preserves them."""
|
|
186
|
+
if not self.workflow_file.is_file():
|
|
187
|
+
return {}
|
|
188
|
+
try:
|
|
189
|
+
raw = yaml.safe_load(self.workflow_file.read_text(encoding="utf-8")) or {}
|
|
190
|
+
except yaml.YAMLError:
|
|
191
|
+
return {}
|
|
192
|
+
if not isinstance(raw, dict) or "workflow" not in raw:
|
|
193
|
+
return {} # bare file: nodes were at the top level, nothing to keep
|
|
194
|
+
return {key: value for key, value in raw.items() if key != "workflow"}
|
|
195
|
+
|
|
196
|
+
def _read_layout(self) -> dict[str, Any]:
|
|
197
|
+
if not self.layout_file.is_file():
|
|
198
|
+
return {}
|
|
199
|
+
try:
|
|
200
|
+
return json.loads(self.layout_file.read_text(encoding="utf-8"))
|
|
201
|
+
except (json.JSONDecodeError, OSError):
|
|
202
|
+
return {}
|
|
203
|
+
|
|
204
|
+
# --- saving ---
|
|
205
|
+
|
|
206
|
+
def check(self, block: dict[str, Any]) -> dict[str, Any]:
|
|
207
|
+
"""Parse and validate a graph without writing it."""
|
|
208
|
+
try:
|
|
209
|
+
workflow = Workflow.from_dict(block, self._agents())
|
|
210
|
+
except WorkflowError as exc:
|
|
211
|
+
return {"ok": False, "issues": [{"level": "error", "message": str(exc)}]}
|
|
212
|
+
|
|
213
|
+
connectors = self._connectors()
|
|
214
|
+
try:
|
|
215
|
+
issues = validate_workflow(workflow, connectors=connectors)
|
|
216
|
+
finally:
|
|
217
|
+
_close(connectors)
|
|
218
|
+
return {
|
|
219
|
+
"ok": not any(i.level == "error" for i in issues),
|
|
220
|
+
"issues": [{"level": i.level, "message": i.message, "node": i.node} for i in issues],
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
def _connectors(self) -> dict[str, Any] | None:
|
|
224
|
+
try:
|
|
225
|
+
config = load_config(self.config_file) if self.config_file.is_file() else {}
|
|
226
|
+
block = config.get("connectors") or {}
|
|
227
|
+
return build_connectors(block) if block else None
|
|
228
|
+
except Exception: # noqa: BLE001
|
|
229
|
+
return None
|
|
230
|
+
|
|
231
|
+
def save(self, block: dict[str, Any], layout: dict[str, Any]) -> dict[str, Any]:
|
|
232
|
+
"""Write the workflow, refusing anything that would not parse."""
|
|
233
|
+
result = self.check(block)
|
|
234
|
+
if not result["ok"]:
|
|
235
|
+
return {"saved": False, **result}
|
|
236
|
+
|
|
237
|
+
# Round-trip through the parser so what lands on disk is normalised, not
|
|
238
|
+
# whatever shape the browser happened to send.
|
|
239
|
+
workflow = Workflow.from_dict(block, self._agents())
|
|
240
|
+
|
|
241
|
+
# Keep every other top-level key the file already had — `agents:` above all.
|
|
242
|
+
# Writing only the workflow would quietly delete the rest of the user's file.
|
|
243
|
+
document = self._existing_document()
|
|
244
|
+
document["workflow"] = workflow.to_dict()
|
|
245
|
+
_write_yaml(self.workflow_file, document, _WORKFLOW_HEADER)
|
|
246
|
+
self.layout_file.write_text(json.dumps(layout, indent=2), encoding="utf-8")
|
|
247
|
+
return {"saved": True, "file": str(self.workflow_file), **result}
|
|
248
|
+
|
|
249
|
+
def save_config(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
250
|
+
"""Write model/connectors/ui to the config, and agents beside the workflow.
|
|
251
|
+
|
|
252
|
+
Setup is saved separately from the graph on purpose: a half-configured
|
|
253
|
+
connector should not block saving the nodes, and a broken node should not
|
|
254
|
+
cost you the credentials you just typed in.
|
|
255
|
+
"""
|
|
256
|
+
raw = self._raw_config()
|
|
257
|
+
for key in _EDITABLE_CONFIG_KEYS:
|
|
258
|
+
value = payload.get(key)
|
|
259
|
+
if value in (None, {}, []):
|
|
260
|
+
raw.pop(key, None)
|
|
261
|
+
else:
|
|
262
|
+
raw[key] = value
|
|
263
|
+
_write_yaml(self.config_file, raw, _CONFIG_HEADER)
|
|
264
|
+
|
|
265
|
+
self._write_agents(payload.get("agents") or {})
|
|
266
|
+
|
|
267
|
+
# Rebuild connectors so the method dropdowns reflect what was just saved —
|
|
268
|
+
# the whole point of typing credentials here is that the next dropdown works.
|
|
269
|
+
methods = self._methods()
|
|
270
|
+
return {
|
|
271
|
+
"saved": True,
|
|
272
|
+
"config_file": str(self.config_file),
|
|
273
|
+
"methods": methods,
|
|
274
|
+
"agents": sorted(self._agents()),
|
|
275
|
+
"issues": self._setup_issues(payload, methods),
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
def _write_agents(self, agents: dict[str, Any]) -> None:
|
|
279
|
+
"""Agents live beside the workflow, above it, as the scaffold writes them."""
|
|
280
|
+
existing = self._existing_document()
|
|
281
|
+
existing.pop("agents", None)
|
|
282
|
+
document: dict[str, Any] = {}
|
|
283
|
+
if agents:
|
|
284
|
+
document["agents"] = agents
|
|
285
|
+
document.update(existing)
|
|
286
|
+
if self.workflow_file.is_file():
|
|
287
|
+
block = self._read_block()
|
|
288
|
+
if block:
|
|
289
|
+
document["workflow"] = block
|
|
290
|
+
_write_yaml(self.workflow_file, document, _WORKFLOW_HEADER)
|
|
291
|
+
|
|
292
|
+
def _setup_issues(
|
|
293
|
+
self, payload: dict[str, Any], methods: dict[str, list[str]]
|
|
294
|
+
) -> list[dict[str, Any]]:
|
|
295
|
+
"""Warn about setup that saved fine but will not work at run time."""
|
|
296
|
+
issues: list[dict[str, Any]] = []
|
|
297
|
+
for name in (payload.get("connectors") or {}):
|
|
298
|
+
if name not in methods:
|
|
299
|
+
issues.append({
|
|
300
|
+
"level": "warning",
|
|
301
|
+
"message": (
|
|
302
|
+
f"Connector '{name}' saved, but could not be opened — check its "
|
|
303
|
+
"credentials and that the ${VAR} names exist in .env. Its methods "
|
|
304
|
+
"will not appear in the dropdowns until it builds."
|
|
305
|
+
),
|
|
306
|
+
})
|
|
307
|
+
for name, spec in (payload.get("agents") or {}).items():
|
|
308
|
+
for ref in (spec or {}).get("tools") or []:
|
|
309
|
+
connector, _, method = str(ref).partition(".")
|
|
310
|
+
if method and connector in methods and method not in methods[connector]:
|
|
311
|
+
issues.append({
|
|
312
|
+
"level": "error",
|
|
313
|
+
"message": f"Agent '{name}': '{connector}' has no method '{method}'.",
|
|
314
|
+
})
|
|
315
|
+
if not issues:
|
|
316
|
+
issues.append({"level": "ok", "message": "Setup saved."})
|
|
317
|
+
return issues
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _write_yaml(path: Path, document: dict[str, Any], header: str) -> None:
|
|
321
|
+
"""Write a YAML document with a header, preserving key order as given."""
|
|
322
|
+
body = yaml.safe_dump(document, sort_keys=False, allow_unicode=True, width=88)
|
|
323
|
+
path.write_text(header + body, encoding="utf-8")
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _close(connectors: dict[str, Any] | None) -> None:
|
|
327
|
+
"""Release connections opened just to inspect a connector.
|
|
328
|
+
|
|
329
|
+
The editor is long-lived, so leaving these open holds a sqlite file locked for
|
|
330
|
+
as long as it runs — on Windows that blocks the agent from touching its own
|
|
331
|
+
database.
|
|
332
|
+
"""
|
|
333
|
+
for connector in (connectors or {}).values():
|
|
334
|
+
try:
|
|
335
|
+
connector.close()
|
|
336
|
+
except Exception: # noqa: BLE001 — best effort, never break the editor
|
|
337
|
+
pass
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _starter() -> dict[str, Any]:
|
|
341
|
+
"""A one-node workflow, so a new project opens with something on the canvas."""
|
|
342
|
+
return {
|
|
343
|
+
"entry": "start",
|
|
344
|
+
"nodes": [{"id": "start", "type": "llm_step", "prompt": "Say hello.", "output": "reply"}],
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _handler_for(state: _EditorState) -> type[BaseHTTPRequestHandler]:
|
|
349
|
+
from roscoe.cli.build_ui import PAGE
|
|
350
|
+
|
|
351
|
+
class _Handler(BaseHTTPRequestHandler):
|
|
352
|
+
def log_message(self, *args: object) -> None:
|
|
353
|
+
pass
|
|
354
|
+
|
|
355
|
+
def handle_one_request(self) -> None:
|
|
356
|
+
"""Swallow the client hanging up.
|
|
357
|
+
|
|
358
|
+
A reload or a cancelled favicon request aborts the socket mid-response,
|
|
359
|
+
and the stdlib prints a full traceback for it. Nothing has gone wrong
|
|
360
|
+
and there is nothing to do, but the noise reads like a crash.
|
|
361
|
+
"""
|
|
362
|
+
try:
|
|
363
|
+
super().handle_one_request()
|
|
364
|
+
except (ConnectionAbortedError, ConnectionResetError, BrokenPipeError):
|
|
365
|
+
self.close_connection = True
|
|
366
|
+
|
|
367
|
+
def _send(self, body: bytes, content_type: str, code: int = 200) -> None:
|
|
368
|
+
self.send_response(code)
|
|
369
|
+
self.send_header("Content-Type", content_type)
|
|
370
|
+
self.send_header("Content-Length", str(len(body)))
|
|
371
|
+
self.end_headers()
|
|
372
|
+
self.wfile.write(body)
|
|
373
|
+
|
|
374
|
+
def _json(self, payload: dict[str, Any], code: int = 200) -> None:
|
|
375
|
+
self._send(json.dumps(payload, default=str).encode(), "application/json", code)
|
|
376
|
+
|
|
377
|
+
def _read(self) -> dict[str, Any]:
|
|
378
|
+
length = int(self.headers.get("Content-Length", 0))
|
|
379
|
+
if not length:
|
|
380
|
+
return {}
|
|
381
|
+
try:
|
|
382
|
+
return json.loads(self.rfile.read(length) or b"{}")
|
|
383
|
+
except json.JSONDecodeError:
|
|
384
|
+
return {}
|
|
385
|
+
|
|
386
|
+
def do_GET(self) -> None: # noqa: N802
|
|
387
|
+
if self.path.startswith("/api/workflow"):
|
|
388
|
+
self._json(state.load())
|
|
389
|
+
return
|
|
390
|
+
self._send(PAGE.encode(), "text/html; charset=utf-8")
|
|
391
|
+
|
|
392
|
+
def do_POST(self) -> None: # noqa: N802
|
|
393
|
+
payload = self._read()
|
|
394
|
+
if self.path.startswith("/api/validate"):
|
|
395
|
+
self._json(state.check(payload.get("workflow") or {}))
|
|
396
|
+
elif self.path.startswith("/api/save-config"):
|
|
397
|
+
self._json(state.save_config(payload))
|
|
398
|
+
elif self.path.startswith("/api/save"):
|
|
399
|
+
self._json(
|
|
400
|
+
state.save(payload.get("workflow") or {}, payload.get("layout") or {})
|
|
401
|
+
)
|
|
402
|
+
else:
|
|
403
|
+
self.send_response(404)
|
|
404
|
+
self.end_headers()
|
|
405
|
+
|
|
406
|
+
return _Handler
|