roscoe 2.2.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.
Files changed (143) hide show
  1. {roscoe-2.2.0/roscoe.egg-info → roscoe-2.4.0}/PKG-INFO +84 -2
  2. {roscoe-2.2.0 → roscoe-2.4.0}/README.md +83 -1
  3. {roscoe-2.2.0 → roscoe-2.4.0}/pyproject.toml +2 -1
  4. roscoe-2.4.0/roscoe/__init__.py +18 -0
  5. roscoe-2.4.0/roscoe/cli/build_command.py +406 -0
  6. roscoe-2.4.0/roscoe/cli/build_ui.py +683 -0
  7. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/cli/eval_gui.py +194 -194
  8. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/cli/google_auth_command.py +99 -99
  9. roscoe-2.4.0/roscoe/cli/graph_command.py +126 -0
  10. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/cli/gui_theme.py +238 -238
  11. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/cli/init_command.py +49 -0
  12. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/cli/main.py +20 -1
  13. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/cli/monitor_gui.py +199 -199
  14. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/cli/pricing_command.py +42 -42
  15. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/cli/pricing_gui.py +147 -147
  16. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/cli/run_command.py +295 -190
  17. roscoe-2.4.0/roscoe/cli/run_web.py +504 -0
  18. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/cli/scaffold/agent_config.yaml +37 -36
  19. roscoe-2.4.0/roscoe/cli/scaffold/custom_ui_example.py +98 -0
  20. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/cli/scaffold/db.py +107 -107
  21. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/cli/scaffold/docs.md +7 -0
  22. roscoe-2.4.0/roscoe/cli/scaffold_nc/agent_config.yaml +71 -0
  23. roscoe-2.4.0/roscoe/cli/scaffold_nc/workflow.yaml +46 -0
  24. roscoe-2.4.0/roscoe/cli/validate_command.py +134 -0
  25. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/config/loader.py +19 -13
  26. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/connectors/__init__.py +6 -1
  27. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/connectors/_graph_base.py +6 -2
  28. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/connectors/base_connector.py +22 -1
  29. roscoe-2.4.0/roscoe/connectors/database.py +259 -0
  30. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/connectors/google_workspace.py +52 -8
  31. roscoe-2.4.0/roscoe/connectors/ticktick.py +114 -0
  32. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/llm/provider_factory.py +30 -15
  33. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/middleware/retry.py +123 -123
  34. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/monitoring/web.py +194 -194
  35. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/pricing.py +63 -63
  36. roscoe-2.4.0/roscoe/workflow/__init__.py +65 -0
  37. roscoe-2.4.0/roscoe/workflow/diagram.py +143 -0
  38. roscoe-2.4.0/roscoe/workflow/executor.py +585 -0
  39. roscoe-2.4.0/roscoe/workflow/expressions.py +403 -0
  40. roscoe-2.4.0/roscoe/workflow/loader.py +97 -0
  41. roscoe-2.4.0/roscoe/workflow/registry.py +94 -0
  42. roscoe-2.4.0/roscoe/workflow/runner.py +282 -0
  43. roscoe-2.4.0/roscoe/workflow/schema.py +391 -0
  44. roscoe-2.4.0/roscoe/workflow/validate.py +301 -0
  45. {roscoe-2.2.0 → roscoe-2.4.0/roscoe.egg-info}/PKG-INFO +84 -2
  46. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe.egg-info/SOURCES.txt +18 -0
  47. roscoe-2.2.0/roscoe/__init__.py +0 -8
  48. roscoe-2.2.0/roscoe/cli/run_web.py +0 -299
  49. {roscoe-2.2.0 → roscoe-2.4.0}/LICENSE +0 -0
  50. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/approval/__init__.py +0 -0
  51. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/approval/gate.py +0 -0
  52. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/cli/__init__.py +0 -0
  53. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/cli/eval_command.py +0 -0
  54. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/cli/monitor_command.py +0 -0
  55. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/cli/scaffold/.env.example +0 -0
  56. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/cli/scaffold/evals/test_cases.json +0 -0
  57. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/cli/scaffold/main.py +0 -0
  58. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/cli/scaffold/prompts/system.txt +0 -0
  59. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/cli/scaffold/tools/my_tools.py +0 -0
  60. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/cli/wizard_gui.py +0 -0
  61. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/config/__init__.py +0 -0
  62. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/connectors/github.py +0 -0
  63. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/connectors/jira.py +0 -0
  64. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/connectors/notion.py +0 -0
  65. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/connectors/outlook.py +0 -0
  66. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/connectors/rest_api.py +0 -0
  67. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/connectors/servicenow.py +0 -0
  68. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/connectors/sharepoint.py +0 -0
  69. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/connectors/snowflake.py +0 -0
  70. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/core/__init__.py +0 -0
  71. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/core/agent_base.py +0 -0
  72. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/core/agent_result.py +0 -0
  73. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/core/agent_runner.py +0 -0
  74. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/core/executor.py +0 -0
  75. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/core/state.py +0 -0
  76. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/evals/__init__.py +0 -0
  77. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/evals/dataset.py +0 -0
  78. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/evals/eval_runner.py +0 -0
  79. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/evals/regression.py +0 -0
  80. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/evals/report.py +0 -0
  81. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/evals/scorers/__init__.py +0 -0
  82. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/evals/scorers/_judge.py +0 -0
  83. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/evals/scorers/base.py +0 -0
  84. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/evals/scorers/hallucination.py +0 -0
  85. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/evals/scorers/output_quality.py +0 -0
  86. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/evals/scorers/tool_usage.py +0 -0
  87. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/llm/__init__.py +0 -0
  88. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/llm/base_provider.py +0 -0
  89. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/llm/capability_map.py +0 -0
  90. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/memory/__init__.py +0 -0
  91. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/memory/conversation.py +0 -0
  92. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/memory/knowledge.py +0 -0
  93. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/memory/persistent.py +0 -0
  94. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/middleware/__init__.py +0 -0
  95. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/middleware/audit_logger.py +0 -0
  96. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/middleware/cost_tracker.py +0 -0
  97. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/middleware/rate_limiter.py +0 -0
  98. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/monitoring/__init__.py +0 -0
  99. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/monitoring/alerts.py +0 -0
  100. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/monitoring/dashboard.py +0 -0
  101. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/monitoring/exporters/__init__.py +0 -0
  102. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/monitoring/exporters/azure_monitor.py +0 -0
  103. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/monitoring/exporters/prometheus.py +0 -0
  104. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/monitoring/metrics.py +0 -0
  105. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/monitoring/notifier.py +0 -0
  106. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/templates/__init__.py +0 -0
  107. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/templates/exec_assistant_agent/__init__.py +0 -0
  108. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/templates/exec_assistant_agent/agent_config.yaml +0 -0
  109. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/templates/exec_assistant_agent/prompts/system.txt +0 -0
  110. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/templates/exec_assistant_agent/tools/__init__.py +0 -0
  111. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/templates/exec_assistant_agent/tools/exec_tools.py +0 -0
  112. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/templates/google_workspace_agent/agent_config.yaml +0 -0
  113. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/templates/google_workspace_agent/prompts/system.txt +0 -0
  114. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/templates/google_workspace_agent/tools/__init__.py +0 -0
  115. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/templates/google_workspace_agent/tools/gws_tools.py +0 -0
  116. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/templates/hr_agent/__init__.py +0 -0
  117. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/templates/hr_agent/agent_config.yaml +0 -0
  118. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/templates/hr_agent/prompts/system.txt +0 -0
  119. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/templates/hr_agent/tools/__init__.py +0 -0
  120. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/templates/hr_agent/tools/hr_tools.py +0 -0
  121. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/templates/it_support_agent/__init__.py +0 -0
  122. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/templates/it_support_agent/agent_config.yaml +0 -0
  123. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/templates/it_support_agent/prompts/system.txt +0 -0
  124. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/templates/it_support_agent/tools/__init__.py +0 -0
  125. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/templates/it_support_agent/tools/it_tools.py +0 -0
  126. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/templates/knowledge_base_agent/__init__.py +0 -0
  127. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/templates/knowledge_base_agent/agent_config.yaml +0 -0
  128. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/templates/knowledge_base_agent/prompts/system.txt +0 -0
  129. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/templates/knowledge_base_agent/tools/__init__.py +0 -0
  130. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/templates/knowledge_base_agent/tools/kb_tools.py +0 -0
  131. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/templates/legal_agent/__init__.py +0 -0
  132. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/templates/legal_agent/agent_config.yaml +0 -0
  133. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/templates/legal_agent/prompts/system.txt +0 -0
  134. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/templates/legal_agent/tools/__init__.py +0 -0
  135. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/templates/legal_agent/tools/legal_tools.py +0 -0
  136. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/tools/__init__.py +0 -0
  137. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe/tools/decorator.py +0 -0
  138. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe.egg-info/dependency_links.txt +0 -0
  139. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe.egg-info/entry_points.txt +0 -0
  140. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe.egg-info/requires.txt +0 -0
  141. {roscoe-2.2.0 → roscoe-2.4.0}/roscoe.egg-info/top_level.txt +0 -0
  142. {roscoe-2.2.0 → roscoe-2.4.0}/setup.cfg +0 -0
  143. {roscoe-2.2.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.2.0
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.2.0"
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