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.
- roscoe/__init__.py +8 -0
- roscoe/approval/__init__.py +5 -0
- roscoe/approval/gate.py +73 -0
- roscoe/cli/__init__.py +5 -0
- roscoe/cli/eval_command.py +79 -0
- roscoe/cli/init_command.py +486 -0
- roscoe/cli/main.py +25 -0
- roscoe/cli/monitor_command.py +29 -0
- roscoe/cli/scaffold/.env.example +43 -0
- roscoe/cli/scaffold/agent_config.yaml +176 -0
- roscoe/cli/scaffold/docs.md +659 -0
- roscoe/cli/scaffold/evals/test_cases.json +37 -0
- roscoe/cli/scaffold/main.py +42 -0
- roscoe/cli/scaffold/prompts/system.txt +26 -0
- roscoe/cli/scaffold/tools/my_tools.py +60 -0
- roscoe/cli/wizard_gui.py +343 -0
- roscoe/config/__init__.py +0 -0
- roscoe/config/loader.py +76 -0
- roscoe/connectors/__init__.py +30 -0
- roscoe/connectors/_graph_base.py +63 -0
- roscoe/connectors/base_connector.py +59 -0
- roscoe/connectors/github.py +79 -0
- roscoe/connectors/google_workspace.py +229 -0
- roscoe/connectors/jira.py +103 -0
- roscoe/connectors/notion.py +87 -0
- roscoe/connectors/outlook.py +90 -0
- roscoe/connectors/rest_api.py +71 -0
- roscoe/connectors/servicenow.py +93 -0
- roscoe/connectors/sharepoint.py +66 -0
- roscoe/connectors/snowflake.py +93 -0
- roscoe/core/__init__.py +8 -0
- roscoe/core/agent_base.py +32 -0
- roscoe/core/agent_result.py +37 -0
- roscoe/core/agent_runner.py +375 -0
- roscoe/core/executor.py +124 -0
- roscoe/core/state.py +18 -0
- roscoe/evals/__init__.py +34 -0
- roscoe/evals/dataset.py +61 -0
- roscoe/evals/eval_runner.py +86 -0
- roscoe/evals/regression.py +54 -0
- roscoe/evals/report.py +47 -0
- roscoe/evals/scorers/__init__.py +15 -0
- roscoe/evals/scorers/_judge.py +31 -0
- roscoe/evals/scorers/base.py +43 -0
- roscoe/evals/scorers/hallucination.py +44 -0
- roscoe/evals/scorers/output_quality.py +42 -0
- roscoe/evals/scorers/tool_usage.py +40 -0
- roscoe/llm/__init__.py +7 -0
- roscoe/llm/base_provider.py +33 -0
- roscoe/llm/capability_map.py +53 -0
- roscoe/llm/provider_factory.py +160 -0
- roscoe/memory/__init__.py +7 -0
- roscoe/memory/conversation.py +34 -0
- roscoe/memory/knowledge.py +100 -0
- roscoe/memory/persistent.py +60 -0
- roscoe/middleware/__init__.py +18 -0
- roscoe/middleware/audit_logger.py +85 -0
- roscoe/middleware/cost_tracker.py +70 -0
- roscoe/middleware/rate_limiter.py +67 -0
- roscoe/middleware/retry.py +95 -0
- roscoe/monitoring/__init__.py +28 -0
- roscoe/monitoring/alerts.py +65 -0
- roscoe/monitoring/dashboard.py +41 -0
- roscoe/monitoring/exporters/__init__.py +5 -0
- roscoe/monitoring/exporters/azure_monitor.py +52 -0
- roscoe/monitoring/exporters/prometheus.py +70 -0
- roscoe/monitoring/metrics.py +148 -0
- roscoe/monitoring/notifier.py +64 -0
- roscoe/templates/__init__.py +35 -0
- roscoe/templates/exec_assistant_agent/__init__.py +0 -0
- roscoe/templates/exec_assistant_agent/agent_config.yaml +34 -0
- roscoe/templates/exec_assistant_agent/prompts/system.txt +16 -0
- roscoe/templates/exec_assistant_agent/tools/__init__.py +0 -0
- roscoe/templates/exec_assistant_agent/tools/exec_tools.py +16 -0
- roscoe/templates/google_workspace_agent/agent_config.yaml +32 -0
- roscoe/templates/google_workspace_agent/prompts/system.txt +16 -0
- roscoe/templates/google_workspace_agent/tools/__init__.py +0 -0
- roscoe/templates/google_workspace_agent/tools/gws_tools.py +16 -0
- roscoe/templates/hr_agent/__init__.py +0 -0
- roscoe/templates/hr_agent/agent_config.yaml +37 -0
- roscoe/templates/hr_agent/prompts/system.txt +13 -0
- roscoe/templates/hr_agent/tools/__init__.py +0 -0
- roscoe/templates/hr_agent/tools/hr_tools.py +68 -0
- roscoe/templates/it_support_agent/__init__.py +0 -0
- roscoe/templates/it_support_agent/agent_config.yaml +32 -0
- roscoe/templates/it_support_agent/prompts/system.txt +15 -0
- roscoe/templates/it_support_agent/tools/__init__.py +0 -0
- roscoe/templates/it_support_agent/tools/it_tools.py +66 -0
- roscoe/templates/knowledge_base_agent/__init__.py +0 -0
- roscoe/templates/knowledge_base_agent/agent_config.yaml +34 -0
- roscoe/templates/knowledge_base_agent/prompts/system.txt +11 -0
- roscoe/templates/knowledge_base_agent/tools/__init__.py +0 -0
- roscoe/templates/knowledge_base_agent/tools/kb_tools.py +49 -0
- roscoe/templates/legal_agent/__init__.py +0 -0
- roscoe/templates/legal_agent/agent_config.yaml +26 -0
- roscoe/templates/legal_agent/prompts/system.txt +12 -0
- roscoe/templates/legal_agent/tools/__init__.py +0 -0
- roscoe/templates/legal_agent/tools/legal_tools.py +68 -0
- roscoe/tools/__init__.py +5 -0
- roscoe/tools/decorator.py +59 -0
- roscoe-0.1.0.dist-info/METADATA +477 -0
- roscoe-0.1.0.dist-info/RECORD +106 -0
- roscoe-0.1.0.dist-info/WHEEL +5 -0
- roscoe-0.1.0.dist-info/entry_points.txt +2 -0
- roscoe-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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__"]
|
roscoe/approval/gate.py
ADDED
|
@@ -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,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()
|