api-operator 0.9.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.
- api_operator/__init__.py +8 -0
- api_operator/adapters/__init__.py +1 -0
- api_operator/adapters/base.py +25 -0
- api_operator/adapters/mock.py +145 -0
- api_operator/adapters/openapi_generator.py +238 -0
- api_operator/adapters/registry.py +71 -0
- api_operator/adapters/yaml_adapter.py +175 -0
- api_operator/adapters/yaml_spec.py +154 -0
- api_operator/core/agent.py +160 -0
- api_operator/core/config.py +18 -0
- api_operator/core/executor.py +38 -0
- api_operator/core/guardrails.py +53 -0
- api_operator/core/memory.py +46 -0
- api_operator/core/planner.py +189 -0
- api_operator/core/planner_openai.py +71 -0
- api_operator/factory.py +26 -0
- api_operator/rag/indexer.py +37 -0
- api_operator/server/app.py +114 -0
- api_operator/server/cli.py +201 -0
- api_operator/tools/base.py +69 -0
- api_operator/tools/schema.py +34 -0
- api_operator-0.9.0.dist-info/METADATA +206 -0
- api_operator-0.9.0.dist-info/RECORD +26 -0
- api_operator-0.9.0.dist-info/WHEEL +4 -0
- api_operator-0.9.0.dist-info/entry_points.txt +2 -0
- api_operator-0.9.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class ParameterSpec:
|
|
12
|
+
type: str = "string"
|
|
13
|
+
required: bool = False
|
|
14
|
+
default: Any = None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class ToolSpec:
|
|
19
|
+
name: str
|
|
20
|
+
description: str
|
|
21
|
+
method: str
|
|
22
|
+
path: str
|
|
23
|
+
parameters: dict[str, ParameterSpec] = field(default_factory=dict)
|
|
24
|
+
body: dict[str, Any] | None = None
|
|
25
|
+
query: dict[str, str] | None = None
|
|
26
|
+
dangerous: bool = False
|
|
27
|
+
requires_ability: str | None = None
|
|
28
|
+
host: str = "central" # central | tenant
|
|
29
|
+
tenant_param: str = "subdomain"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class AdapterSpec:
|
|
34
|
+
name: str
|
|
35
|
+
description: str
|
|
36
|
+
base_url: str
|
|
37
|
+
system_prompt: str
|
|
38
|
+
tools: list[ToolSpec]
|
|
39
|
+
auth_type: str = "bearer"
|
|
40
|
+
auth_header: str = "Authorization"
|
|
41
|
+
token_env: str | None = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def load_adapter_spec(path: str | Path) -> AdapterSpec:
|
|
45
|
+
file_path = Path(path)
|
|
46
|
+
if not file_path.exists():
|
|
47
|
+
raise FileNotFoundError(f"Adapter config not found: {file_path}")
|
|
48
|
+
|
|
49
|
+
raw = yaml.safe_load(file_path.read_text(encoding="utf-8"))
|
|
50
|
+
if not isinstance(raw, dict):
|
|
51
|
+
raise ValueError("Adapter YAML must be a mapping")
|
|
52
|
+
|
|
53
|
+
auth = raw.get("auth") or {}
|
|
54
|
+
tools: list[ToolSpec] = []
|
|
55
|
+
for item in raw.get("tools") or []:
|
|
56
|
+
if not isinstance(item, dict):
|
|
57
|
+
continue
|
|
58
|
+
params: dict[str, ParameterSpec] = {}
|
|
59
|
+
raw_params = item.get("parameters") or {}
|
|
60
|
+
if isinstance(raw_params, dict):
|
|
61
|
+
for pname, pspec in raw_params.items():
|
|
62
|
+
if isinstance(pspec, dict):
|
|
63
|
+
params[pname] = ParameterSpec(
|
|
64
|
+
type=str(pspec.get("type", "string")),
|
|
65
|
+
required=bool(pspec.get("required", False)),
|
|
66
|
+
default=pspec.get("default"),
|
|
67
|
+
)
|
|
68
|
+
else:
|
|
69
|
+
params[pname] = ParameterSpec(required=bool(pspec))
|
|
70
|
+
|
|
71
|
+
tools.append(
|
|
72
|
+
ToolSpec(
|
|
73
|
+
name=str(item["name"]),
|
|
74
|
+
description=str(item.get("description", item["name"])),
|
|
75
|
+
method=str(item.get("method", "GET")).upper(),
|
|
76
|
+
path=str(item["path"]),
|
|
77
|
+
parameters=params,
|
|
78
|
+
body=item.get("body"),
|
|
79
|
+
query=item.get("query"),
|
|
80
|
+
dangerous=bool(item.get("dangerous", False)),
|
|
81
|
+
requires_ability=item.get("requires_ability"),
|
|
82
|
+
host=str(item.get("host", "central")),
|
|
83
|
+
tenant_param=str(item.get("tenant_param", "subdomain")),
|
|
84
|
+
)
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
return AdapterSpec(
|
|
88
|
+
name=str(raw.get("name", file_path.stem)),
|
|
89
|
+
description=str(raw.get("description", "")),
|
|
90
|
+
base_url=str(raw.get("base_url", "")).rstrip("/"),
|
|
91
|
+
system_prompt=str(raw.get("system_prompt") or _default_prompt(raw)),
|
|
92
|
+
tools=tools,
|
|
93
|
+
auth_type=str(auth.get("type", "bearer")),
|
|
94
|
+
auth_header=str(auth.get("header", "Authorization")),
|
|
95
|
+
token_env=auth.get("env_token"),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def save_adapter_spec(spec: AdapterSpec, path: str | Path) -> None:
|
|
100
|
+
file_path = Path(path)
|
|
101
|
+
payload: dict[str, Any] = {
|
|
102
|
+
"name": spec.name,
|
|
103
|
+
"description": spec.description,
|
|
104
|
+
"base_url": spec.base_url,
|
|
105
|
+
"auth": {"type": spec.auth_type, "header": spec.auth_header},
|
|
106
|
+
"system_prompt": spec.system_prompt,
|
|
107
|
+
"tools": [],
|
|
108
|
+
}
|
|
109
|
+
if spec.token_env:
|
|
110
|
+
payload["auth"]["env_token"] = spec.token_env
|
|
111
|
+
|
|
112
|
+
for tool in spec.tools:
|
|
113
|
+
entry: dict[str, Any] = {
|
|
114
|
+
"name": tool.name,
|
|
115
|
+
"description": tool.description,
|
|
116
|
+
"method": tool.method,
|
|
117
|
+
"path": tool.path,
|
|
118
|
+
}
|
|
119
|
+
if tool.host != "central":
|
|
120
|
+
entry["host"] = tool.host
|
|
121
|
+
entry["tenant_param"] = tool.tenant_param
|
|
122
|
+
if tool.dangerous:
|
|
123
|
+
entry["dangerous"] = True
|
|
124
|
+
if tool.requires_ability:
|
|
125
|
+
entry["requires_ability"] = tool.requires_ability
|
|
126
|
+
if tool.parameters:
|
|
127
|
+
entry["parameters"] = {
|
|
128
|
+
name: {
|
|
129
|
+
"type": param.type,
|
|
130
|
+
"required": param.required,
|
|
131
|
+
**({"default": param.default} if param.default is not None else {}),
|
|
132
|
+
}
|
|
133
|
+
for name, param in tool.parameters.items()
|
|
134
|
+
}
|
|
135
|
+
if tool.body:
|
|
136
|
+
entry["body"] = tool.body
|
|
137
|
+
if tool.query:
|
|
138
|
+
entry["query"] = tool.query
|
|
139
|
+
payload["tools"].append(entry)
|
|
140
|
+
|
|
141
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
142
|
+
file_path.write_text(
|
|
143
|
+
yaml.safe_dump(payload, sort_keys=False, allow_unicode=True),
|
|
144
|
+
encoding="utf-8",
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _default_prompt(raw: dict[str, Any]) -> str:
|
|
149
|
+
name = raw.get("name", "project")
|
|
150
|
+
return (
|
|
151
|
+
f"You are API Operator for {name}. "
|
|
152
|
+
"Use registered tools only. Confirm dangerous actions. "
|
|
153
|
+
"Reply in the user's language."
|
|
154
|
+
)
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from api_operator.adapters.base import Adapter
|
|
7
|
+
from api_operator.core.config import Settings
|
|
8
|
+
from api_operator.core.executor import ToolExecutor
|
|
9
|
+
from api_operator.core.guardrails import Guardrails
|
|
10
|
+
from api_operator.core.memory import Session, SessionStore
|
|
11
|
+
from api_operator.core.planner import Planner
|
|
12
|
+
from api_operator.core.planner_openai import build_planner
|
|
13
|
+
from api_operator.tools.base import ToolRegistry
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class AgentResponse:
|
|
18
|
+
session_id: str
|
|
19
|
+
message: str
|
|
20
|
+
status: str = "ok" # ok | confirm | error
|
|
21
|
+
tool: str | None = None
|
|
22
|
+
tool_result: dict[str, Any] | None = None
|
|
23
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Agent:
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
adapter: Adapter,
|
|
30
|
+
settings: Settings | None = None,
|
|
31
|
+
planner: Planner | None = None,
|
|
32
|
+
sessions: SessionStore | None = None,
|
|
33
|
+
) -> None:
|
|
34
|
+
self.settings = settings or Settings()
|
|
35
|
+
self.adapter = adapter
|
|
36
|
+
self.registry: ToolRegistry = adapter.build_registry()
|
|
37
|
+
self.guardrails = Guardrails(
|
|
38
|
+
require_confirm_dangerous=self.settings.require_confirm_dangerous
|
|
39
|
+
)
|
|
40
|
+
self.executor = ToolExecutor(self.guardrails)
|
|
41
|
+
self.planner = planner or build_planner(self.settings)
|
|
42
|
+
self.sessions = sessions or SessionStore()
|
|
43
|
+
|
|
44
|
+
async def chat(
|
|
45
|
+
self,
|
|
46
|
+
message: str,
|
|
47
|
+
session_id: str | None = None,
|
|
48
|
+
abilities: list[str] | None = None,
|
|
49
|
+
auto_confirm: bool = False,
|
|
50
|
+
) -> AgentResponse:
|
|
51
|
+
session = self.sessions.get_or_create(session_id, self.adapter.name)
|
|
52
|
+
session.add("user", message)
|
|
53
|
+
|
|
54
|
+
if session.pending_confirmation and not auto_confirm:
|
|
55
|
+
if self.guardrails.is_confirmation(message):
|
|
56
|
+
pending = session.pending_confirmation
|
|
57
|
+
session.pending_confirmation = None
|
|
58
|
+
result, error = await self.executor.execute(
|
|
59
|
+
self.registry,
|
|
60
|
+
pending["tool_name"],
|
|
61
|
+
pending["tool_args"],
|
|
62
|
+
abilities=abilities,
|
|
63
|
+
)
|
|
64
|
+
return self._finalize_tool(session, pending["tool_name"], result, error)
|
|
65
|
+
|
|
66
|
+
if self.guardrails.is_cancellation(message):
|
|
67
|
+
session.pending_confirmation = None
|
|
68
|
+
reply = "Cancelled. No changes were made."
|
|
69
|
+
session.add("assistant", reply, status="ok")
|
|
70
|
+
return AgentResponse(session_id=session.id, message=reply, status="ok")
|
|
71
|
+
|
|
72
|
+
plan = await self.planner.plan(
|
|
73
|
+
message=message,
|
|
74
|
+
registry=self.registry,
|
|
75
|
+
history=session.history(),
|
|
76
|
+
system_prompt=self.adapter.system_prompt(),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
if plan.type == "respond":
|
|
80
|
+
session.add("assistant", plan.content, status="ok")
|
|
81
|
+
return AgentResponse(session_id=session.id, message=plan.content, status="ok")
|
|
82
|
+
|
|
83
|
+
if plan.type == "clarify":
|
|
84
|
+
session.add("assistant", plan.content, status="ok")
|
|
85
|
+
return AgentResponse(session_id=session.id, message=plan.content, status="ok")
|
|
86
|
+
|
|
87
|
+
if plan.type != "tool" or not plan.tool_name:
|
|
88
|
+
reply = "Could not determine an action."
|
|
89
|
+
session.add("assistant", reply, status="error")
|
|
90
|
+
return AgentResponse(session_id=session.id, message=reply, status="error")
|
|
91
|
+
|
|
92
|
+
tool = self.registry.get(plan.tool_name)
|
|
93
|
+
if tool is None:
|
|
94
|
+
reply = f"Tool not found: {plan.tool_name}"
|
|
95
|
+
session.add("assistant", reply, status="error")
|
|
96
|
+
return AgentResponse(session_id=session.id, message=reply, status="error")
|
|
97
|
+
|
|
98
|
+
if self.guardrails.needs_confirmation(tool) and not auto_confirm:
|
|
99
|
+
session.pending_confirmation = {
|
|
100
|
+
"tool_name": plan.tool_name,
|
|
101
|
+
"tool_args": plan.tool_args or {},
|
|
102
|
+
}
|
|
103
|
+
confirm_msg = self.executor.confirmation_message(tool, plan.tool_args or {})
|
|
104
|
+
session.add("assistant", confirm_msg, status="confirm")
|
|
105
|
+
return AgentResponse(
|
|
106
|
+
session_id=session.id,
|
|
107
|
+
message=confirm_msg,
|
|
108
|
+
status="confirm",
|
|
109
|
+
tool=plan.tool_name,
|
|
110
|
+
metadata={"arguments": plan.tool_args or {}},
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
result, error = await self.executor.execute(
|
|
114
|
+
self.registry,
|
|
115
|
+
plan.tool_name,
|
|
116
|
+
plan.tool_args,
|
|
117
|
+
abilities=abilities,
|
|
118
|
+
)
|
|
119
|
+
return self._finalize_tool(session, plan.tool_name, result, error)
|
|
120
|
+
|
|
121
|
+
def _finalize_tool(
|
|
122
|
+
self,
|
|
123
|
+
session: Session,
|
|
124
|
+
tool_name: str,
|
|
125
|
+
result: Any,
|
|
126
|
+
error: str | None,
|
|
127
|
+
) -> AgentResponse:
|
|
128
|
+
if error:
|
|
129
|
+
session.add("assistant", error, status="error", tool=tool_name)
|
|
130
|
+
return AgentResponse(
|
|
131
|
+
session_id=session.id,
|
|
132
|
+
message=error,
|
|
133
|
+
status="error",
|
|
134
|
+
tool=tool_name,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
payload = result.to_dict() if result else {"ok": False}
|
|
138
|
+
if payload.get("ok"):
|
|
139
|
+
reply = f"{tool_name} succeeded: {payload.get('data')}"
|
|
140
|
+
session.add("assistant", reply, status="ok", tool=tool_name)
|
|
141
|
+
return AgentResponse(
|
|
142
|
+
session_id=session.id,
|
|
143
|
+
message=reply,
|
|
144
|
+
status="ok",
|
|
145
|
+
tool=tool_name,
|
|
146
|
+
tool_result=payload,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
err = payload.get("error", "Tool failed.")
|
|
150
|
+
session.add("assistant", err, status="error", tool=tool_name)
|
|
151
|
+
return AgentResponse(
|
|
152
|
+
session_id=session.id,
|
|
153
|
+
message=str(err),
|
|
154
|
+
status="error",
|
|
155
|
+
tool=tool_name,
|
|
156
|
+
tool_result=payload,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
def list_tools(self) -> list[dict[str, Any]]:
|
|
160
|
+
return self.registry.schemas()
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Settings(BaseSettings):
|
|
5
|
+
model_config = SettingsConfigDict(
|
|
6
|
+
env_prefix="api_operator_",
|
|
7
|
+
env_file=".env",
|
|
8
|
+
extra="ignore",
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
host: str = "127.0.0.1"
|
|
12
|
+
port: int = 8100
|
|
13
|
+
default_adapter: str = "mock"
|
|
14
|
+
planner: str = "auto" # auto | mock | openai
|
|
15
|
+
openai_api_key: str | None = None
|
|
16
|
+
openai_model: str = "gpt-4o-mini"
|
|
17
|
+
require_confirm_dangerous: bool = True
|
|
18
|
+
log_tool_calls: bool = True
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from api_operator.core.guardrails import Guardrails
|
|
6
|
+
from api_operator.tools.base import Tool, ToolRegistry, ToolResult
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ToolExecutor:
|
|
10
|
+
def __init__(self, guardrails: Guardrails | None = None) -> None:
|
|
11
|
+
self.guardrails = guardrails or Guardrails()
|
|
12
|
+
|
|
13
|
+
async def execute(
|
|
14
|
+
self,
|
|
15
|
+
registry: ToolRegistry,
|
|
16
|
+
tool_name: str,
|
|
17
|
+
arguments: dict[str, Any] | None,
|
|
18
|
+
abilities: list[str] | None = None,
|
|
19
|
+
) -> tuple[ToolResult | None, str | None]:
|
|
20
|
+
tool = registry.get(tool_name)
|
|
21
|
+
if tool is None:
|
|
22
|
+
return None, f"Unknown tool: {tool_name}"
|
|
23
|
+
|
|
24
|
+
permission_error = self.guardrails.check_ability(tool, abilities)
|
|
25
|
+
if permission_error:
|
|
26
|
+
return None, permission_error
|
|
27
|
+
|
|
28
|
+
args = arguments or {}
|
|
29
|
+
missing = self.guardrails.missing_parameters(tool, args)
|
|
30
|
+
if missing:
|
|
31
|
+
return None, f"Missing required parameters: {', '.join(missing)}"
|
|
32
|
+
|
|
33
|
+
result = await tool.run(**args)
|
|
34
|
+
return result, None
|
|
35
|
+
|
|
36
|
+
def confirmation_message(self, tool: Tool, arguments: dict[str, Any]) -> str:
|
|
37
|
+
rendered = ", ".join(f"{k}={v!r}" for k, v in arguments.items())
|
|
38
|
+
return f"Confirm {tool.name}({rendered})? Reply yes to proceed or no to cancel."
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from api_operator.tools.base import Tool
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Guardrails:
|
|
9
|
+
def __init__(self, require_confirm_dangerous: bool = True) -> None:
|
|
10
|
+
self.require_confirm_dangerous = require_confirm_dangerous
|
|
11
|
+
|
|
12
|
+
def check_ability(self, tool: Tool, abilities: list[str] | None) -> str | None:
|
|
13
|
+
if tool.requires_ability is None:
|
|
14
|
+
return None
|
|
15
|
+
if abilities is None:
|
|
16
|
+
return None
|
|
17
|
+
if tool.requires_ability not in abilities:
|
|
18
|
+
return (
|
|
19
|
+
f"Permission denied: tool '{tool.name}' requires "
|
|
20
|
+
f"ability '{tool.requires_ability}'."
|
|
21
|
+
)
|
|
22
|
+
return None
|
|
23
|
+
|
|
24
|
+
def missing_parameters(self, tool: Tool, arguments: dict[str, Any]) -> list[str]:
|
|
25
|
+
missing: list[str] = []
|
|
26
|
+
for name in tool.required:
|
|
27
|
+
value = arguments.get(name)
|
|
28
|
+
if value is None or (isinstance(value, str) and not value.strip()):
|
|
29
|
+
missing.append(name)
|
|
30
|
+
return missing
|
|
31
|
+
|
|
32
|
+
def needs_confirmation(self, tool: Tool) -> bool:
|
|
33
|
+
return self.require_confirm_dangerous and tool.dangerous
|
|
34
|
+
|
|
35
|
+
@staticmethod
|
|
36
|
+
def is_confirmation(message: str) -> bool:
|
|
37
|
+
normalized = message.strip().lower()
|
|
38
|
+
return normalized in {
|
|
39
|
+
"yes",
|
|
40
|
+
"y",
|
|
41
|
+
"confirm",
|
|
42
|
+
"ok",
|
|
43
|
+
"okay",
|
|
44
|
+
"نعم",
|
|
45
|
+
"أكد",
|
|
46
|
+
"اكد",
|
|
47
|
+
"موافق",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
@staticmethod
|
|
51
|
+
def is_cancellation(message: str) -> bool:
|
|
52
|
+
normalized = message.strip().lower()
|
|
53
|
+
return normalized in {"no", "n", "cancel", "stop", "لا", "الغ", "إلغاء", "الغاء"}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any
|
|
5
|
+
from uuid import uuid4
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class Message:
|
|
10
|
+
role: str
|
|
11
|
+
content: str
|
|
12
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class Session:
|
|
17
|
+
id: str
|
|
18
|
+
adapter: str
|
|
19
|
+
messages: list[Message] = field(default_factory=list)
|
|
20
|
+
context: dict[str, Any] = field(default_factory=dict)
|
|
21
|
+
pending_confirmation: dict[str, Any] | None = None
|
|
22
|
+
|
|
23
|
+
def add(self, role: str, content: str, **metadata: Any) -> None:
|
|
24
|
+
self.messages.append(Message(role=role, content=content, metadata=metadata))
|
|
25
|
+
|
|
26
|
+
def history(self, limit: int = 20) -> list[dict[str, str]]:
|
|
27
|
+
return [{"role": m.role, "content": m.content} for m in self.messages[-limit:]]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class SessionStore:
|
|
31
|
+
def __init__(self) -> None:
|
|
32
|
+
self._sessions: dict[str, Session] = {}
|
|
33
|
+
|
|
34
|
+
def create(self, adapter: str, session_id: str | None = None) -> Session:
|
|
35
|
+
sid = session_id or str(uuid4())
|
|
36
|
+
session = Session(id=sid, adapter=adapter)
|
|
37
|
+
self._sessions[sid] = session
|
|
38
|
+
return session
|
|
39
|
+
|
|
40
|
+
def get(self, session_id: str) -> Session | None:
|
|
41
|
+
return self._sessions.get(session_id)
|
|
42
|
+
|
|
43
|
+
def get_or_create(self, session_id: str | None, adapter: str) -> Session:
|
|
44
|
+
if session_id and session_id in self._sessions:
|
|
45
|
+
return self._sessions[session_id]
|
|
46
|
+
return self.create(adapter=adapter, session_id=session_id)
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any, Protocol
|
|
5
|
+
|
|
6
|
+
from api_operator.tools.base import ToolRegistry
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class PlanStep:
|
|
11
|
+
type: str # respond | tool | clarify
|
|
12
|
+
content: str = ""
|
|
13
|
+
tool_name: str | None = None
|
|
14
|
+
tool_args: dict[str, Any] | None = None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Planner(Protocol):
|
|
18
|
+
async def plan(
|
|
19
|
+
self,
|
|
20
|
+
message: str,
|
|
21
|
+
registry: ToolRegistry,
|
|
22
|
+
history: list[dict[str, str]],
|
|
23
|
+
system_prompt: str,
|
|
24
|
+
) -> PlanStep: ...
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class MockPlanner:
|
|
28
|
+
"""Rule-based planner for offline demos and tests (no LLM required)."""
|
|
29
|
+
|
|
30
|
+
async def plan(
|
|
31
|
+
self,
|
|
32
|
+
message: str,
|
|
33
|
+
registry: ToolRegistry,
|
|
34
|
+
history: list[dict[str, str]],
|
|
35
|
+
system_prompt: str,
|
|
36
|
+
) -> PlanStep:
|
|
37
|
+
text = message.strip()
|
|
38
|
+
lower = text.lower()
|
|
39
|
+
|
|
40
|
+
if "help" in lower or "مساعدة" in text or lower.strip() == "tools":
|
|
41
|
+
names = ", ".join(registry.names())
|
|
42
|
+
return PlanStep(
|
|
43
|
+
type="respond",
|
|
44
|
+
content=(
|
|
45
|
+
"I can run these tools: "
|
|
46
|
+
f"{names}. Try: 'list workspaces', 'create workspace Acme subdomain acme', "
|
|
47
|
+
"'invite admin@acme.com to acme', 'provision link Riyadh Jeddah 500'."
|
|
48
|
+
),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
if _wants_create_workspace(lower):
|
|
52
|
+
args = _extract_workspace_args(text)
|
|
53
|
+
if args.get("name") and args.get("subdomain") and registry.get("create_workspace"):
|
|
54
|
+
return PlanStep(
|
|
55
|
+
type="tool",
|
|
56
|
+
tool_name="create_workspace",
|
|
57
|
+
tool_args=args,
|
|
58
|
+
)
|
|
59
|
+
return PlanStep(
|
|
60
|
+
type="clarify",
|
|
61
|
+
content="Please provide workspace name and subdomain (e.g. name Acme, subdomain acme).",
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
if _wants_invite(lower, text):
|
|
65
|
+
tool_name = "invite_member" if registry.get("invite_member") else "invite_team_member"
|
|
66
|
+
if registry.get(tool_name):
|
|
67
|
+
args = _extract_invite_args(text)
|
|
68
|
+
if args.get("email"):
|
|
69
|
+
subdomain = args.get("subdomain") or "acme"
|
|
70
|
+
return PlanStep(
|
|
71
|
+
type="tool",
|
|
72
|
+
tool_name=tool_name,
|
|
73
|
+
tool_args={
|
|
74
|
+
"subdomain": subdomain,
|
|
75
|
+
"email": args["email"],
|
|
76
|
+
"role": args.get("role", "member"),
|
|
77
|
+
},
|
|
78
|
+
)
|
|
79
|
+
return PlanStep(type="clarify", content="Which email should I invite?")
|
|
80
|
+
|
|
81
|
+
if _wants_provision_link(lower):
|
|
82
|
+
args = _extract_link_args(text)
|
|
83
|
+
if args.get("site_a") and args.get("site_b") and registry.get("provision_link"):
|
|
84
|
+
return PlanStep(type="tool", tool_name="provision_link", tool_args=args)
|
|
85
|
+
|
|
86
|
+
if _wants_list_connections(lower, text) and registry.get("list_connections"):
|
|
87
|
+
return PlanStep(type="tool", tool_name="list_connections", tool_args={})
|
|
88
|
+
|
|
89
|
+
if _wants_list_workspaces(lower, text) and registry.get("list_workspaces"):
|
|
90
|
+
return PlanStep(type="tool", tool_name="list_workspaces", tool_args={})
|
|
91
|
+
|
|
92
|
+
return PlanStep(
|
|
93
|
+
type="respond",
|
|
94
|
+
content=(
|
|
95
|
+
"I did not map that request to a tool. Say 'help' to see examples, or be explicit "
|
|
96
|
+
"(e.g. 'list workspaces', 'create workspace Acme subdomain acme')."
|
|
97
|
+
),
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _wants_create_workspace(lower: str) -> bool:
|
|
102
|
+
return (
|
|
103
|
+
"create workspace" in lower
|
|
104
|
+
or "new workspace" in lower
|
|
105
|
+
or "أنشئ workspace" in lower
|
|
106
|
+
or "انشئ workspace" in lower
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _wants_invite(lower: str, text: str) -> bool:
|
|
111
|
+
return "invite" in lower or "ادع" in lower or "دعوة" in text
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _wants_provision_link(lower: str) -> bool:
|
|
115
|
+
return "provision link" in lower or ("provision" in lower and "link" in lower)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _wants_list_connections(lower: str, text: str) -> bool:
|
|
119
|
+
if not ("connection" in lower or "link" in lower or "اتصال" in text or "رابط" in text):
|
|
120
|
+
return False
|
|
121
|
+
return any(word in lower for word in ("list", "show", "display")) or "اعرض" in text
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _wants_list_workspaces(lower: str, text: str) -> bool:
|
|
125
|
+
if "workspace" not in lower and "workspace" not in text:
|
|
126
|
+
return False
|
|
127
|
+
if _wants_create_workspace(lower):
|
|
128
|
+
return False
|
|
129
|
+
return (
|
|
130
|
+
"list workspace" in lower
|
|
131
|
+
or "list workspaces" in lower
|
|
132
|
+
or "show workspace" in lower
|
|
133
|
+
or "show workspaces" in lower
|
|
134
|
+
or lower.strip() == "workspaces"
|
|
135
|
+
or "ورّيني" in text
|
|
136
|
+
or "اعرض" in text
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _extract_workspace_args(text: str) -> dict[str, Any]:
|
|
141
|
+
args: dict[str, Any] = {}
|
|
142
|
+
lower = text.lower()
|
|
143
|
+
if "subdomain" in lower:
|
|
144
|
+
parts = lower.split("subdomain", 1)[1].strip().split()
|
|
145
|
+
if parts:
|
|
146
|
+
args["subdomain"] = parts[0].strip(",.")
|
|
147
|
+
if "name" in lower:
|
|
148
|
+
segment = text.split("name", 1)[1]
|
|
149
|
+
if "subdomain" in segment.lower():
|
|
150
|
+
segment = segment.split("subdomain", 1)[0]
|
|
151
|
+
args["name"] = segment.strip().strip(",.")
|
|
152
|
+
tokens = text.replace(",", " ").split()
|
|
153
|
+
for idx, token in enumerate(tokens):
|
|
154
|
+
if token.lower() == "workspace" and idx + 1 < len(tokens):
|
|
155
|
+
maybe_name = tokens[idx + 1]
|
|
156
|
+
if maybe_name.lower() not in {"subdomain", "named", "on"}:
|
|
157
|
+
args.setdefault("name", maybe_name)
|
|
158
|
+
if token.lower() == "subdomain" and idx + 1 < len(tokens):
|
|
159
|
+
args["subdomain"] = tokens[idx + 1].strip(".,")
|
|
160
|
+
return args
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _extract_invite_args(text: str) -> dict[str, Any]:
|
|
164
|
+
args: dict[str, Any] = {}
|
|
165
|
+
for token in text.replace(",", " ").split():
|
|
166
|
+
if "@" in token:
|
|
167
|
+
args["email"] = token.strip(".,")
|
|
168
|
+
lower = text.lower()
|
|
169
|
+
if " to " in lower:
|
|
170
|
+
subdomain = lower.split(" to ", 1)[1].strip().split()[0]
|
|
171
|
+
args["subdomain"] = subdomain.strip(".,")
|
|
172
|
+
for token in text.split():
|
|
173
|
+
if token.lower() in {"admin", "member"}:
|
|
174
|
+
args["role"] = token.lower()
|
|
175
|
+
return args
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _extract_link_args(text: str) -> dict[str, Any]:
|
|
179
|
+
args: dict[str, Any] = {}
|
|
180
|
+
lower = text.lower()
|
|
181
|
+
if "provision" in lower and "link" in lower:
|
|
182
|
+
tail = text.lower().split("link", 1)[1].strip()
|
|
183
|
+
parts = tail.split()
|
|
184
|
+
if len(parts) >= 2:
|
|
185
|
+
args["site_a"] = parts[0].capitalize()
|
|
186
|
+
args["site_b"] = parts[1].capitalize()
|
|
187
|
+
if len(parts) >= 3 and parts[2].isdigit():
|
|
188
|
+
args["bandwidth_mbps"] = int(parts[2])
|
|
189
|
+
return args
|