roscoe 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. roscoe/__init__.py +8 -0
  2. roscoe/approval/__init__.py +5 -0
  3. roscoe/approval/gate.py +73 -0
  4. roscoe/cli/__init__.py +5 -0
  5. roscoe/cli/eval_command.py +79 -0
  6. roscoe/cli/init_command.py +486 -0
  7. roscoe/cli/main.py +25 -0
  8. roscoe/cli/monitor_command.py +29 -0
  9. roscoe/cli/scaffold/.env.example +43 -0
  10. roscoe/cli/scaffold/agent_config.yaml +176 -0
  11. roscoe/cli/scaffold/docs.md +659 -0
  12. roscoe/cli/scaffold/evals/test_cases.json +37 -0
  13. roscoe/cli/scaffold/main.py +42 -0
  14. roscoe/cli/scaffold/prompts/system.txt +26 -0
  15. roscoe/cli/scaffold/tools/my_tools.py +60 -0
  16. roscoe/cli/wizard_gui.py +343 -0
  17. roscoe/config/__init__.py +0 -0
  18. roscoe/config/loader.py +76 -0
  19. roscoe/connectors/__init__.py +30 -0
  20. roscoe/connectors/_graph_base.py +63 -0
  21. roscoe/connectors/base_connector.py +59 -0
  22. roscoe/connectors/github.py +79 -0
  23. roscoe/connectors/google_workspace.py +229 -0
  24. roscoe/connectors/jira.py +103 -0
  25. roscoe/connectors/notion.py +87 -0
  26. roscoe/connectors/outlook.py +90 -0
  27. roscoe/connectors/rest_api.py +71 -0
  28. roscoe/connectors/servicenow.py +93 -0
  29. roscoe/connectors/sharepoint.py +66 -0
  30. roscoe/connectors/snowflake.py +93 -0
  31. roscoe/core/__init__.py +8 -0
  32. roscoe/core/agent_base.py +32 -0
  33. roscoe/core/agent_result.py +37 -0
  34. roscoe/core/agent_runner.py +375 -0
  35. roscoe/core/executor.py +124 -0
  36. roscoe/core/state.py +18 -0
  37. roscoe/evals/__init__.py +34 -0
  38. roscoe/evals/dataset.py +61 -0
  39. roscoe/evals/eval_runner.py +86 -0
  40. roscoe/evals/regression.py +54 -0
  41. roscoe/evals/report.py +47 -0
  42. roscoe/evals/scorers/__init__.py +15 -0
  43. roscoe/evals/scorers/_judge.py +31 -0
  44. roscoe/evals/scorers/base.py +43 -0
  45. roscoe/evals/scorers/hallucination.py +44 -0
  46. roscoe/evals/scorers/output_quality.py +42 -0
  47. roscoe/evals/scorers/tool_usage.py +40 -0
  48. roscoe/llm/__init__.py +7 -0
  49. roscoe/llm/base_provider.py +33 -0
  50. roscoe/llm/capability_map.py +53 -0
  51. roscoe/llm/provider_factory.py +160 -0
  52. roscoe/memory/__init__.py +7 -0
  53. roscoe/memory/conversation.py +34 -0
  54. roscoe/memory/knowledge.py +100 -0
  55. roscoe/memory/persistent.py +60 -0
  56. roscoe/middleware/__init__.py +18 -0
  57. roscoe/middleware/audit_logger.py +85 -0
  58. roscoe/middleware/cost_tracker.py +70 -0
  59. roscoe/middleware/rate_limiter.py +67 -0
  60. roscoe/middleware/retry.py +95 -0
  61. roscoe/monitoring/__init__.py +28 -0
  62. roscoe/monitoring/alerts.py +65 -0
  63. roscoe/monitoring/dashboard.py +41 -0
  64. roscoe/monitoring/exporters/__init__.py +5 -0
  65. roscoe/monitoring/exporters/azure_monitor.py +52 -0
  66. roscoe/monitoring/exporters/prometheus.py +70 -0
  67. roscoe/monitoring/metrics.py +148 -0
  68. roscoe/monitoring/notifier.py +64 -0
  69. roscoe/templates/__init__.py +35 -0
  70. roscoe/templates/exec_assistant_agent/__init__.py +0 -0
  71. roscoe/templates/exec_assistant_agent/agent_config.yaml +34 -0
  72. roscoe/templates/exec_assistant_agent/prompts/system.txt +16 -0
  73. roscoe/templates/exec_assistant_agent/tools/__init__.py +0 -0
  74. roscoe/templates/exec_assistant_agent/tools/exec_tools.py +16 -0
  75. roscoe/templates/google_workspace_agent/agent_config.yaml +32 -0
  76. roscoe/templates/google_workspace_agent/prompts/system.txt +16 -0
  77. roscoe/templates/google_workspace_agent/tools/__init__.py +0 -0
  78. roscoe/templates/google_workspace_agent/tools/gws_tools.py +16 -0
  79. roscoe/templates/hr_agent/__init__.py +0 -0
  80. roscoe/templates/hr_agent/agent_config.yaml +37 -0
  81. roscoe/templates/hr_agent/prompts/system.txt +13 -0
  82. roscoe/templates/hr_agent/tools/__init__.py +0 -0
  83. roscoe/templates/hr_agent/tools/hr_tools.py +68 -0
  84. roscoe/templates/it_support_agent/__init__.py +0 -0
  85. roscoe/templates/it_support_agent/agent_config.yaml +32 -0
  86. roscoe/templates/it_support_agent/prompts/system.txt +15 -0
  87. roscoe/templates/it_support_agent/tools/__init__.py +0 -0
  88. roscoe/templates/it_support_agent/tools/it_tools.py +66 -0
  89. roscoe/templates/knowledge_base_agent/__init__.py +0 -0
  90. roscoe/templates/knowledge_base_agent/agent_config.yaml +34 -0
  91. roscoe/templates/knowledge_base_agent/prompts/system.txt +11 -0
  92. roscoe/templates/knowledge_base_agent/tools/__init__.py +0 -0
  93. roscoe/templates/knowledge_base_agent/tools/kb_tools.py +49 -0
  94. roscoe/templates/legal_agent/__init__.py +0 -0
  95. roscoe/templates/legal_agent/agent_config.yaml +26 -0
  96. roscoe/templates/legal_agent/prompts/system.txt +12 -0
  97. roscoe/templates/legal_agent/tools/__init__.py +0 -0
  98. roscoe/templates/legal_agent/tools/legal_tools.py +68 -0
  99. roscoe/tools/__init__.py +5 -0
  100. roscoe/tools/decorator.py +59 -0
  101. roscoe-0.1.0.dist-info/METADATA +477 -0
  102. roscoe-0.1.0.dist-info/RECORD +106 -0
  103. roscoe-0.1.0.dist-info/WHEEL +5 -0
  104. roscoe-0.1.0.dist-info/entry_points.txt +2 -0
  105. roscoe-0.1.0.dist-info/licenses/LICENSE +21 -0
  106. roscoe-0.1.0.dist-info/top_level.txt +1 -0
roscoe/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """roscoe — provider-agnostic LangChain agent framework with middleware and evals."""
2
+
3
+ from roscoe.core.agent_result import AgentResult
4
+ from roscoe.core.agent_runner import AgentRunner
5
+
6
+ __version__ = "0.1.0"
7
+
8
+ __all__ = ["AgentRunner", "AgentResult", "__version__"]
@@ -0,0 +1,5 @@
1
+ """Human-approval (HITL) primitives for the langgraph-free ReAct loop."""
2
+
3
+ from roscoe.approval.gate import ApprovalGate, PendingRun, PendingStore
4
+
5
+ __all__ = ["ApprovalGate", "PendingRun", "PendingStore"]
@@ -0,0 +1,73 @@
1
+ """Human-approval gate and pending-run store (langgraph-free HITL).
2
+
3
+ roscoe runs its own ReAct loop, so human-in-the-loop is simple: when the model asks to
4
+ call a tool whose name is listed in ``require_approval_for``, the loop **stops** before
5
+ executing it and returns a ``paused`` result describing the pending tool call(s). A
6
+ human then approves / rejects / modifies out of band, and ``AgentRunner.resume()``
7
+ continues the run.
8
+
9
+ No checkpointer is needed: the paused run's message history is held in a
10
+ ``PendingStore`` keyed by ``run_id``. The default store is in-process (good for a single
11
+ worker / dev). Swap in a durable store (sqlite, redis) for multi-process deployments.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass
17
+ from typing import Any
18
+
19
+
20
+ class ApprovalGate:
21
+ """Decides whether a tool call needs human approval before it runs."""
22
+
23
+ def __init__(self, require_approval_for: list[str] | None = None) -> None:
24
+ self._gated: set[str] = set(require_approval_for or [])
25
+
26
+ def needs_approval(self, tool_name: str) -> bool:
27
+ return tool_name in self._gated
28
+
29
+ @property
30
+ def is_active(self) -> bool:
31
+ return bool(self._gated)
32
+
33
+
34
+ @dataclass
35
+ class PendingRun:
36
+ """A run suspended awaiting human approval.
37
+
38
+ Holds everything needed to resume: the conversation so far (the last message is the
39
+ AIMessage carrying the gated ``tool_calls``), plus the run's identity so memory and
40
+ audit can be updated on resume.
41
+ """
42
+
43
+ run_id: str
44
+ messages: list[Any]
45
+ tool_calls: list[dict[str, Any]]
46
+ user_id: str | None = None
47
+ session_id: str | None = None
48
+ human_message: Any | None = None
49
+
50
+
51
+ class PendingStore:
52
+ """In-process store of paused runs, keyed by ``run_id``.
53
+
54
+ Intentionally tiny. For durability across process restarts or multiple workers,
55
+ subclass and persist (sqlite/redis) — ``save`` / ``pop`` / ``get`` are the contract.
56
+ """
57
+
58
+ def __init__(self) -> None:
59
+ self._runs: dict[str, PendingRun] = {}
60
+
61
+ def save(self, run: PendingRun) -> None:
62
+ self._runs[run.run_id] = run
63
+
64
+ def get(self, run_id: str) -> PendingRun | None:
65
+ return self._runs.get(run_id)
66
+
67
+ def pop(self, run_id: str) -> PendingRun:
68
+ if run_id not in self._runs:
69
+ raise KeyError(
70
+ f"No paused run with id '{run_id}'. It may have already been resumed, "
71
+ f"or this process didn't create it (the default store is in-memory)."
72
+ )
73
+ return self._runs.pop(run_id)
roscoe/cli/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """roscoe command-line interface."""
2
+
3
+ from roscoe.cli.main import cli
4
+
5
+ __all__ = ["cli"]
@@ -0,0 +1,79 @@
1
+ """``roscoe eval`` — run an eval suite from the terminal.
2
+
3
+ Builds an agent from a config, runs a dataset through it, and prints a scored report.
4
+ The deterministic tool-usage scorer always runs; add ``--judge`` to also run the
5
+ LLM-as-judge output-quality scorer (uses the config's model as the judge).
6
+
7
+ Custom tools live in Python, so point ``--tools`` at a ``module:attribute`` that resolves
8
+ to a list of tools (e.g. ``tools.my_tools:TOOLS``).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import importlib
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ import click
18
+
19
+ from roscoe.core.agent_runner import AgentRunner
20
+ from roscoe.evals import EvalRunner, load_dataset, render_report
21
+ from roscoe.evals.scorers import OutputQualityScorer, ToolUsageScorer
22
+ from roscoe.llm.provider_factory import ProviderFactory
23
+
24
+
25
+ def _load_tools(tools_ref: str | None) -> list[Any]:
26
+ """Resolve a ``module:attribute`` reference to a list of tools."""
27
+ if not tools_ref:
28
+ return []
29
+ if ":" not in tools_ref:
30
+ raise ValueError("--tools must be 'module:attribute', e.g. tools.my_tools:TOOLS")
31
+ module_name, attr = tools_ref.split(":", 1)
32
+ module = importlib.import_module(module_name)
33
+ tools = getattr(module, attr)
34
+ if callable(tools): # a build_tools-style factory taking no args
35
+ tools = tools()
36
+ return list(tools)
37
+
38
+
39
+ def run_eval(
40
+ dataset_path: str | Path,
41
+ config_path: str | Path,
42
+ *,
43
+ tools_ref: str | None = None,
44
+ use_judge: bool = False,
45
+ pass_threshold: float = 0.7,
46
+ ):
47
+ """Run the eval suite and return an EvalReport."""
48
+ cases = load_dataset(dataset_path)
49
+ tools = _load_tools(tools_ref)
50
+ agent = AgentRunner.from_config(config_path, tools=tools)
51
+
52
+ scorers: list[Any] = [ToolUsageScorer()]
53
+ if use_judge:
54
+ from roscoe.config.loader import load_config
55
+
56
+ judge = ProviderFactory.get_llm(load_config(config_path)["model"])
57
+ scorers.append(OutputQualityScorer(judge))
58
+
59
+ return EvalRunner(agent, scorers, pass_threshold=pass_threshold).run(cases)
60
+
61
+
62
+ @click.command("eval")
63
+ @click.option("--dataset", required=True, help="Path to the test_cases.json dataset.")
64
+ @click.option("--config", required=True, help="Path to the agent config YAML.")
65
+ @click.option("--tools", "tools_ref", default=None, help="module:attribute resolving to a tool list.")
66
+ @click.option("--judge/--no-judge", default=False, help="Also run the LLM output-quality scorer.")
67
+ @click.option("--threshold", default=0.7, show_default=True, help="Pass threshold for the overall score.")
68
+ def eval_command(dataset: str, config: str, tools_ref: str | None, judge: bool, threshold: float) -> None:
69
+ """Run an eval suite against an agent config and print a scored report."""
70
+ try:
71
+ report = run_eval(
72
+ dataset, config, tools_ref=tools_ref, use_judge=judge, pass_threshold=threshold
73
+ )
74
+ except (FileNotFoundError, ValueError) as exc:
75
+ raise click.ClickException(str(exc)) from exc
76
+
77
+ click.echo(render_report(report))
78
+ if not report.passed:
79
+ raise SystemExit(1)
@@ -0,0 +1,486 @@
1
+ """``roscoe init`` — scaffold a new agent project.
2
+
3
+ Blank scaffold (default) copies ``cli/scaffold/`` and substitutes the project name.
4
+ ``--template`` copies a pre-built template from ``roscoe.templates`` and adds the
5
+ project-level files a template doesn't carry (``main.py``, ``.env.example``,
6
+ ``evals/test_cases.json``). No Jinja2 — a single ``__PROJECT_NAME__`` placeholder is
7
+ enough, keeping the dependency surface small.
8
+
9
+ Interactive wizard (blank projects only, skip with ``--quick``):
10
+ Prompts for provider, model, middleware toggles, and memory. Answers are written
11
+ into ``agent_config.yaml`` with all comments preserved — the user can always edit
12
+ the YAML later.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import shutil
19
+ from pathlib import Path
20
+
21
+ import click
22
+
23
+ from roscoe.templates import available_templates, template_path
24
+
25
+ SCAFFOLD_DIR = Path(__file__).parent / "scaffold"
26
+ PLACEHOLDER = "__PROJECT_NAME__"
27
+ _RENDER_SUFFIXES = {".yaml", ".yml", ".txt", ".py", ".md", ".json"}
28
+
29
+ _PROVIDERS = {
30
+ "openai": {"env_key": "OPENAI_API_KEY", "default_model": "gpt-4o-mini", "base_url": None},
31
+ "openrouter": {"env_key": "OPENROUTER_API_KEY", "default_model": "meta-llama/llama-3.1-8b-instruct", "base_url": "https://openrouter.ai/api/v1"},
32
+ "azure_openai": {"env_key": "AZURE_OPENAI_KEY", "default_model": "gpt-4o", "base_url": None},
33
+ "anthropic": {"env_key": "ANTHROPIC_API_KEY", "default_model": "claude-sonnet-4-5", "base_url": None},
34
+ "gemini": {"env_key": "GOOGLE_API_KEY", "default_model": "gemini-1.5-pro", "base_url": None},
35
+ "ollama": {"env_key": None, "default_model": "llama3.1", "base_url": None},
36
+ }
37
+
38
+ # Per-template entry point: how to build that template's tools.
39
+ _TEMPLATE_MAIN = {
40
+ "hr_agent": '''"""Entry point for the HR agent. Run: python main.py"""
41
+
42
+ from roscoe import AgentRunner
43
+ from roscoe.connectors import RESTConnector
44
+
45
+ from tools.hr_tools import build_tools
46
+
47
+ rest = RESTConnector(
48
+ {"base_url": __import__("os").environ["HR_API_BASE_URL"], "auth": "bearer",
49
+ "token": __import__("os").environ["HR_API_TOKEN"]}
50
+ )
51
+ agent = AgentRunner.from_config("agent_config.yaml", tools=build_tools(rest))
52
+
53
+ if __name__ == "__main__":
54
+ print(agent.run("How many leave days does employee E1 have left?").output)
55
+ ''',
56
+ "it_support_agent": '''"""Entry point for the IT support agent. Run: python main.py"""
57
+
58
+ import os
59
+
60
+ from roscoe import AgentRunner
61
+ from roscoe.connectors import ServiceNowConnector
62
+
63
+ from tools.it_tools import build_tools
64
+
65
+ snow = ServiceNowConnector(
66
+ {"instance_url": os.environ["SERVICENOW_INSTANCE_URL"],
67
+ "username": os.environ["SERVICENOW_USERNAME"],
68
+ "password": os.environ["SERVICENOW_PASSWORD"]}
69
+ )
70
+ agent = AgentRunner.from_config("agent_config.yaml", tools=build_tools(snow))
71
+
72
+ if __name__ == "__main__":
73
+ print(agent.run("My laptop won't connect to wifi, please open a ticket.").output)
74
+ ''',
75
+ "legal_agent": '''"""Entry point for the legal agent. Run: python main.py"""
76
+
77
+ from roscoe import AgentRunner
78
+ from roscoe.memory.knowledge import KnowledgeMemory
79
+
80
+ from tools.legal_tools import build_tools
81
+
82
+ # Replace with your real contract texts (and embeddings for semantic search).
83
+ contracts = ["Limitation of liability: ... ", "Term and termination: ..."]
84
+ knowledge = KnowledgeMemory.from_texts(
85
+ contracts, metadatas=[{"source": "contract.pdf"}, {"source": "contract.pdf"}]
86
+ )
87
+ agent = AgentRunner.from_config("agent_config.yaml", tools=build_tools(knowledge))
88
+
89
+ if __name__ == "__main__":
90
+ print(agent.run("What does the liability clause say?").output)
91
+ ''',
92
+ "knowledge_base_agent": '''"""Entry point for the knowledge-base agent. Run: python main.py"""
93
+
94
+ import os
95
+
96
+ from roscoe import AgentRunner
97
+ from roscoe.connectors import NotionConnector
98
+
99
+ from tools.kb_tools import build_tools
100
+
101
+ # Wire whichever sources you have. Add SharePointConnector / KnowledgeMemory as needed.
102
+ notion = NotionConnector({"token": os.environ["NOTION_TOKEN"]})
103
+ agent = AgentRunner.from_config("agent_config.yaml", tools=build_tools(notion=notion))
104
+
105
+ if __name__ == "__main__":
106
+ print(agent.run("What is our remote-work policy?").output)
107
+ ''',
108
+ "exec_assistant_agent": '''"""Entry point for the executive assistant. Run: python main.py"""
109
+
110
+ import os
111
+
112
+ from roscoe import AgentRunner
113
+ from roscoe.connectors import OutlookConnector
114
+
115
+ from tools.exec_tools import build_tools
116
+
117
+ outlook = OutlookConnector(
118
+ {"client_id": os.environ["MS_CLIENT_ID"],
119
+ "client_secret": os.environ["MS_CLIENT_SECRET"],
120
+ "tenant_id": os.environ["MS_TENANT_ID"],
121
+ "mailbox": os.environ["MS_MAILBOX"]}
122
+ )
123
+ agent = AgentRunner.from_config("agent_config.yaml", tools=build_tools(outlook))
124
+
125
+ if __name__ == "__main__":
126
+ print(agent.run("Summarize my unread emails.").output)
127
+ ''',
128
+ "google_workspace_agent": '''"""Entry point for the Google Workspace agent. Run: python main.py"""
129
+
130
+ import os
131
+
132
+ from roscoe import AgentRunner
133
+ from roscoe.connectors import GoogleWorkspaceConnector
134
+
135
+ from tools.gws_tools import build_tools
136
+
137
+ google = GoogleWorkspaceConnector(
138
+ {"credentials_file": os.environ["GOOGLE_SA_KEY_FILE"],
139
+ "subject": os.environ["GOOGLE_SUBJECT"]}
140
+ )
141
+ agent = AgentRunner.from_config("agent_config.yaml", tools=build_tools(google))
142
+
143
+ if __name__ == "__main__":
144
+ print(agent.run("Summarize my unread emails and list today's meetings.").output)
145
+ ''',
146
+ }
147
+
148
+ _TEMPLATE_ENV = {
149
+ "hr_agent": "OPENAI_API_KEY=\nHR_API_BASE_URL=\nHR_API_TOKEN=\n",
150
+ "it_support_agent": (
151
+ "OPENAI_API_KEY=\nSERVICENOW_INSTANCE_URL=\n"
152
+ "SERVICENOW_USERNAME=\nSERVICENOW_PASSWORD=\n"
153
+ ),
154
+ "legal_agent": "OPENAI_API_KEY=\n",
155
+ "knowledge_base_agent": (
156
+ "OPENAI_API_KEY=\nNOTION_TOKEN=\n"
157
+ "# SharePoint (optional):\n# MS_CLIENT_ID=\n# MS_CLIENT_SECRET=\n"
158
+ "# MS_TENANT_ID=\n# SP_SITE_ID=\n"
159
+ ),
160
+ "exec_assistant_agent": (
161
+ "OPENAI_API_KEY=\nMS_CLIENT_ID=\nMS_CLIENT_SECRET=\n"
162
+ "MS_TENANT_ID=\nMS_MAILBOX=\n"
163
+ ),
164
+ "google_workspace_agent": (
165
+ "OPENAI_API_KEY=\n"
166
+ "GOOGLE_SA_KEY_FILE=path/to/service-account.json\n"
167
+ "GOOGLE_SUBJECT=user@yourdomain.com\n"
168
+ ),
169
+ }
170
+
171
+ _EXAMPLE_CASES = {
172
+ "cases": [
173
+ {"id": "example-1", "input": "Replace with a real test input.", "expected_output": "..."}
174
+ ]
175
+ }
176
+
177
+
178
+ # ---------------------------------------------------------------------------
179
+ # Interactive wizard
180
+ # ---------------------------------------------------------------------------
181
+
182
+ def _run_wizard(name: str) -> dict:
183
+ """Prompt the user for project settings. Returns a dict of choices."""
184
+ click.echo()
185
+ click.secho(" roscoe — new agent setup", bold=True)
186
+ click.secho(" Configure your agent. All choices go into agent_config.yaml.", dim=True)
187
+ click.secho(" You can change everything later by editing the YAML.\n", dim=True)
188
+
189
+ # --- Provider ---
190
+ click.echo("LLM Provider:")
191
+ providers = list(_PROVIDERS.keys())
192
+ _LABELS = {
193
+ "openai": "openai",
194
+ "openrouter": "openrouter (100+ models, one API key)",
195
+ "azure_openai": "azure_openai",
196
+ "anthropic": "anthropic",
197
+ "gemini": "gemini",
198
+ "ollama": "ollama (free, local, no API key)",
199
+ }
200
+ for i, p in enumerate(providers, 1):
201
+ click.echo(f" [{i}] {_LABELS.get(p, p)}")
202
+ choice = click.prompt(
203
+ "Choose provider",
204
+ type=click.IntRange(1, len(providers)),
205
+ default=1,
206
+ )
207
+ provider = providers[choice - 1]
208
+ pinfo = _PROVIDERS[provider]
209
+
210
+ # --- Model ---
211
+ model = click.prompt("Model name", default=pinfo["default_model"])
212
+
213
+ # --- Temperature ---
214
+ temperature = click.prompt("Temperature (0.0 = precise, 1.0 = creative)", default=0.1, type=float)
215
+
216
+ # --- Base URL ---
217
+ base_url = pinfo["base_url"]
218
+ if provider == "openai":
219
+ if click.confirm("Using a custom endpoint (Together, etc.)?", default=False):
220
+ base_url = click.prompt("Base URL")
221
+
222
+ # --- Azure extras ---
223
+ azure_deployment = None
224
+ if provider == "azure_openai":
225
+ azure_deployment = click.prompt("Azure deployment name", default=model)
226
+
227
+ # --- Middleware ---
228
+ click.echo("\nMiddleware (all recommended for production):")
229
+ cost_tracking = click.confirm(" Enable cost tracking?", default=True)
230
+ rate_limiting = click.confirm(" Enable rate limiting?", default=True)
231
+ rpm = 60
232
+ if rate_limiting:
233
+ rpm = click.prompt(" Requests per minute", default=60, type=int)
234
+ retry = click.confirm(" Enable auto-retry on failures?", default=True)
235
+ retry_attempts = 3
236
+ if retry:
237
+ retry_attempts = click.prompt(" Max retry attempts", default=3, type=int)
238
+ audit = click.confirm(" Enable audit logging?", default=True)
239
+
240
+ # --- Human approval ---
241
+ click.echo("\nHuman-in-the-loop:")
242
+ human_approval = click.confirm(" Require approval before running certain tools?", default=False)
243
+ approval_tools: list[str] = []
244
+ if human_approval:
245
+ click.secho(" Enter tool function names that need sign-off (comma-separated).", dim=True)
246
+ click.secho(" Example: send_email, delete_record, submit_payment", dim=True)
247
+ raw = click.prompt(" Tools requiring approval")
248
+ approval_tools = [t.strip() for t in raw.split(",") if t.strip()]
249
+
250
+ # --- Memory ---
251
+ click.echo("\nMemory:")
252
+ conversation_memory = click.confirm(" Enable conversation memory (remembers within a session)?", default=True)
253
+ window_size = 10
254
+ if conversation_memory:
255
+ window_size = click.prompt(" Conversation window size (messages to keep)", default=10, type=int)
256
+ persistent_memory = click.confirm(" Enable persistent memory (remembers across sessions, sqlite)?", default=False)
257
+
258
+ click.echo()
259
+
260
+ return {
261
+ "provider": provider,
262
+ "model": model,
263
+ "temperature": temperature,
264
+ "base_url": base_url,
265
+ "azure_deployment": azure_deployment,
266
+ "env_key": pinfo["env_key"],
267
+ "cost_tracking": cost_tracking,
268
+ "rate_limiting": rate_limiting,
269
+ "rpm": rpm,
270
+ "retry": retry,
271
+ "retry_attempts": retry_attempts,
272
+ "audit": audit,
273
+ "conversation_memory": conversation_memory,
274
+ "window_size": window_size,
275
+ "persistent_memory": persistent_memory,
276
+ "human_approval": human_approval,
277
+ "approval_tools": approval_tools,
278
+ }
279
+
280
+
281
+ def _apply_wizard(dest: Path, answers: dict) -> None:
282
+ """Rewrite agent_config.yaml with the wizard answers, keeping all comments."""
283
+ cfg_path = dest / "agent_config.yaml"
284
+ text = cfg_path.read_text()
285
+
286
+ # OpenRouter uses the openai provider with a base_url
287
+ config_provider = "openai" if answers["provider"] == "openrouter" else answers["provider"]
288
+
289
+ # Provider + model block
290
+ model_block = f" provider: {config_provider}\n"
291
+ if answers["azure_deployment"]:
292
+ model_block += f" deployment: {answers['azure_deployment']}\n"
293
+ model_block += f" endpoint: ${{{answers['env_key']}_ENDPOINT}}\n" # noqa: E501
294
+ else:
295
+ model_block += f" model: {answers['model']}\n"
296
+ if answers["env_key"]:
297
+ model_block += f" api_key: ${{{answers['env_key']}}}\n"
298
+ model_block += f" temperature: {answers['temperature']}\n"
299
+ if answers["base_url"]:
300
+ model_block += f" base_url: {answers['base_url']}\n"
301
+
302
+ # Replace the active model block (non-comment lines between "model:" and next section)
303
+ import re
304
+ model_section = re.search(
305
+ r"(^model:\n)((?:[ \t]+(?!#).*\n)+)",
306
+ text,
307
+ re.MULTILINE,
308
+ )
309
+ if model_section:
310
+ text = text[: model_section.start(2)] + model_block + text[model_section.end(2) :]
311
+
312
+ # Middleware toggles
313
+ _swap = {
314
+ "enabled: true": "enabled: true",
315
+ "enabled: false": "enabled: false",
316
+ }
317
+
318
+ # Cost tracking
319
+ if not answers["cost_tracking"]:
320
+ text = text.replace(
321
+ " cost_tracking:\n enabled: true",
322
+ " cost_tracking:\n enabled: false",
323
+ )
324
+
325
+ # Rate limiting
326
+ if not answers["rate_limiting"]:
327
+ text = text.replace(
328
+ " rate_limiter:\n enabled: true\n requests_per_minute: 60",
329
+ " rate_limiter:\n enabled: false\n requests_per_minute: 60",
330
+ )
331
+ else:
332
+ text = text.replace(
333
+ " requests_per_minute: 60",
334
+ f" requests_per_minute: {answers['rpm']}",
335
+ )
336
+
337
+ # Retry
338
+ if not answers["retry"]:
339
+ text = text.replace(
340
+ " retry:\n max_attempts: 3",
341
+ " # retry:\n # max_attempts: 3",
342
+ )
343
+ else:
344
+ text = text.replace(" max_attempts: 3", f" max_attempts: {answers['retry_attempts']}")
345
+
346
+ # Audit
347
+ if not answers["audit"]:
348
+ text = text.replace(
349
+ " audit:\n enabled: true",
350
+ " audit:\n enabled: false",
351
+ )
352
+
353
+ # Human approval
354
+ if answers["human_approval"] and answers["approval_tools"]:
355
+ tools_yaml = ", ".join(f'"{t}"' for t in answers["approval_tools"])
356
+ text = text.replace(
357
+ " # human_approval:\n"
358
+ " # require_approval_for:\n"
359
+ " # - send_email # tool function names that need sign-off\n"
360
+ " # - delete_record\n"
361
+ " # - submit_payment",
362
+ f" human_approval:\n"
363
+ f" require_approval_for: [{tools_yaml}]",
364
+ )
365
+
366
+ # Memory
367
+ if not answers["conversation_memory"]:
368
+ text = text.replace(
369
+ " conversation:\n enabled: true\n window_size: 10",
370
+ " conversation:\n enabled: false\n window_size: 10",
371
+ )
372
+ else:
373
+ text = text.replace(" window_size: 10", f" window_size: {answers['window_size']}")
374
+
375
+ if answers["persistent_memory"]:
376
+ text = text.replace(
377
+ " enabled: false # flip to true to enable cross-session memory",
378
+ " enabled: true",
379
+ )
380
+
381
+ cfg_path.write_text(text)
382
+
383
+
384
+ # ---------------------------------------------------------------------------
385
+ # Scaffold logic
386
+ # ---------------------------------------------------------------------------
387
+
388
+ def scaffold_project(
389
+ name: str,
390
+ *,
391
+ template: str | None = None,
392
+ dest_dir: str | Path = ".",
393
+ wizard_answers: dict | None = None,
394
+ ) -> Path:
395
+ """Create a new project directory. Returns the path created."""
396
+ dest = Path(dest_dir) / name
397
+ if dest.exists():
398
+ raise FileExistsError(f"Destination already exists: {dest}")
399
+
400
+ if template:
401
+ shutil.copytree(
402
+ template_path(template), dest, ignore=shutil.ignore_patterns("__pycache__")
403
+ )
404
+ _add_template_extras(dest, template)
405
+ else:
406
+ shutil.copytree(SCAFFOLD_DIR, dest)
407
+
408
+ _render_placeholders(dest, name)
409
+
410
+ if wizard_answers:
411
+ _apply_wizard(dest, wizard_answers)
412
+
413
+ return dest
414
+
415
+
416
+ def _add_template_extras(dest: Path, template: str) -> None:
417
+ (dest / "main.py").write_text(_TEMPLATE_MAIN[template])
418
+ (dest / ".env.example").write_text(_TEMPLATE_ENV[template])
419
+ evals_dir = dest / "evals"
420
+ evals_dir.mkdir(exist_ok=True)
421
+ (evals_dir / "test_cases.json").write_text(json.dumps(_EXAMPLE_CASES, indent=2))
422
+
423
+
424
+ def _render_placeholders(dest: Path, name: str) -> None:
425
+ for p in dest.rglob("*"):
426
+ if not p.is_file():
427
+ continue
428
+ if p.suffix not in _RENDER_SUFFIXES and p.name != ".env.example":
429
+ continue
430
+ text = p.read_text()
431
+ if PLACEHOLDER in text:
432
+ p.write_text(text.replace(PLACEHOLDER, name))
433
+
434
+
435
+ @click.command("init")
436
+ @click.argument("project_name")
437
+ @click.option(
438
+ "--template",
439
+ type=click.Choice(available_templates()),
440
+ default=None,
441
+ help="Scaffold from a pre-built template instead of a blank project.",
442
+ )
443
+ @click.option(
444
+ "--quick",
445
+ is_flag=True,
446
+ default=False,
447
+ help="Skip the interactive wizard and use defaults.",
448
+ )
449
+ @click.option(
450
+ "--cli",
451
+ is_flag=True,
452
+ default=False,
453
+ help="Force CLI wizard instead of GUI window.",
454
+ )
455
+ def init_command(project_name: str, template: str | None, quick: bool, cli: bool) -> None:
456
+ """Create a new roscoe agent project."""
457
+ wizard_answers = None
458
+ if not template and not quick:
459
+ if cli:
460
+ wizard_answers = _run_wizard(project_name)
461
+ else:
462
+ try:
463
+ from roscoe.cli.wizard_gui import _TkUnavailable, run_wizard_gui
464
+
465
+ wizard_answers = run_wizard_gui(project_name)
466
+ if wizard_answers is None:
467
+ raise click.Abort()
468
+ except _TkUnavailable:
469
+ click.echo("No display available — falling back to CLI wizard.\n")
470
+ wizard_answers = _run_wizard(project_name)
471
+
472
+ try:
473
+ dest = scaffold_project(project_name, template=template, wizard_answers=wizard_answers)
474
+ except FileExistsError as exc:
475
+ raise click.ClickException(str(exc)) from exc
476
+
477
+ kind = f"template '{template}'" if template else "blank project"
478
+ click.echo(click.style(f" Created {kind} at {dest}/", fg="green", bold=True))
479
+ click.echo()
480
+ click.echo(" Next steps:")
481
+ click.echo(f" cd {dest}")
482
+ if not template and wizard_answers and wizard_answers.get("env_key"):
483
+ click.echo(" cp .env.example .env # fill in your API key")
484
+ elif template:
485
+ click.echo(" cp .env.example .env # fill in your keys")
486
+ click.echo(" python main.py")
roscoe/cli/main.py ADDED
@@ -0,0 +1,25 @@
1
+ """roscoe CLI entry point — the ``roscoe`` command group."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+
7
+ from roscoe import __version__
8
+ from roscoe.cli.eval_command import eval_command
9
+ from roscoe.cli.init_command import init_command
10
+ from roscoe.cli.monitor_command import monitor_command
11
+
12
+
13
+ @click.group()
14
+ @click.version_option(__version__, prog_name="roscoe")
15
+ def cli() -> None:
16
+ """roscoe — provider-agnostic agent SDK."""
17
+
18
+
19
+ cli.add_command(init_command)
20
+ cli.add_command(monitor_command)
21
+ cli.add_command(eval_command)
22
+
23
+
24
+ if __name__ == "__main__":
25
+ cli()