apyrobo 3.0.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.
- apyrobo/__init__.py +152 -0
- apyrobo/__main__.py +6 -0
- apyrobo/__version__.py +3 -0
- apyrobo/agents/__init__.py +17 -0
- apyrobo/agents/multiturn.py +163 -0
- apyrobo/agents/tool_agent.py +151 -0
- apyrobo/api/__init__.py +14 -0
- apyrobo/api/app.py +166 -0
- apyrobo/audit.py +244 -0
- apyrobo/auth.py +297 -0
- apyrobo/cli.py +1857 -0
- apyrobo/config.py +408 -0
- apyrobo/core/__init__.py +13 -0
- apyrobo/core/adapters.py +841 -0
- apyrobo/core/health.py +241 -0
- apyrobo/core/robot.py +157 -0
- apyrobo/core/ros2_bridge.py +798 -0
- apyrobo/core/schemas.py +232 -0
- apyrobo/costmap.py +208 -0
- apyrobo/dashboard.py +362 -0
- apyrobo/fleet/__init__.py +18 -0
- apyrobo/fleet/manager.py +334 -0
- apyrobo/fleet/multisite.py +182 -0
- apyrobo/hardware/__init__.py +10 -0
- apyrobo/hardware/autodiscovery.py +138 -0
- apyrobo/hardware/schema.py +144 -0
- apyrobo/inference/__init__.py +32 -0
- apyrobo/inference/edge.py +176 -0
- apyrobo/inference/router.py +1010 -0
- apyrobo/inference/vlm.py +143 -0
- apyrobo/lts/__init__.py +6 -0
- apyrobo/lts/checker.py +79 -0
- apyrobo/lts/policy.py +93 -0
- apyrobo/memory/__init__.py +357 -0
- apyrobo/memory/episodic.py +320 -0
- apyrobo/memory/plan_cache.py +400 -0
- apyrobo/memory/semantic.py +287 -0
- apyrobo/moveit.py +459 -0
- apyrobo/nav2.py +404 -0
- apyrobo/observability.py +998 -0
- apyrobo/operations.py +961 -0
- apyrobo/orchestration/__init__.py +16 -0
- apyrobo/orchestration/adapter.py +263 -0
- apyrobo/persistence.py +767 -0
- apyrobo/plugins/__init__.py +7 -0
- apyrobo/plugins/base.py +89 -0
- apyrobo/plugins/loader.py +144 -0
- apyrobo/plugins/registry.py +137 -0
- apyrobo/profiles/__init__.py +4 -0
- apyrobo/profiles/schema.py +201 -0
- apyrobo/registry/__init__.py +6 -0
- apyrobo/registry/client.py +118 -0
- apyrobo/registry/models.py +52 -0
- apyrobo/registry/server.py +186 -0
- apyrobo/safety/__init__.py +21 -0
- apyrobo/safety/confidence.py +388 -0
- apyrobo/safety/enforcer.py +1180 -0
- apyrobo/safety/verification.py +185 -0
- apyrobo/sensors/__init__.py +11 -0
- apyrobo/sensors/pipeline.py +644 -0
- apyrobo/sensors/ros2_subscribers.py +386 -0
- apyrobo/sim/__init__.py +35 -0
- apyrobo/sim/adapters.py +484 -0
- apyrobo/sim/mujoco.py +153 -0
- apyrobo/sim/twin.py +164 -0
- apyrobo/skills/__init__.py +29 -0
- apyrobo/skills/agent.py +1339 -0
- apyrobo/skills/builtins.py +79 -0
- apyrobo/skills/builtins_extended.py +75 -0
- apyrobo/skills/checkpoint.py +297 -0
- apyrobo/skills/compose.py +475 -0
- apyrobo/skills/corrections.py +217 -0
- apyrobo/skills/decorators.py +152 -0
- apyrobo/skills/demonstrations.py +423 -0
- apyrobo/skills/discovery.py +187 -0
- apyrobo/skills/executor.py +740 -0
- apyrobo/skills/feedback.py +160 -0
- apyrobo/skills/handlers.py +225 -0
- apyrobo/skills/library.py +204 -0
- apyrobo/skills/longterm.py +492 -0
- apyrobo/skills/package.py +435 -0
- apyrobo/skills/plan_validator.py +354 -0
- apyrobo/skills/registry.py +355 -0
- apyrobo/skills/replanner.py +75 -0
- apyrobo/skills/retry.py +286 -0
- apyrobo/skills/simtoreal.py +233 -0
- apyrobo/skills/skill.py +237 -0
- apyrobo/skills/verifier.py +208 -0
- apyrobo/swarm/__init__.py +11 -0
- apyrobo/swarm/bus.py +182 -0
- apyrobo/swarm/coordinator.py +314 -0
- apyrobo/swarm/ros2_bus.py +198 -0
- apyrobo/swarm/safety.py +276 -0
- apyrobo/task_queue.py +325 -0
- apyrobo/tests/test_api.py +110 -0
- apyrobo/tests/test_audit.py +103 -0
- apyrobo/tests/test_checkpoint.py +270 -0
- apyrobo/tests/test_discovery.py +96 -0
- apyrobo/tests/test_feedback.py +102 -0
- apyrobo/tests/test_fleet.py +108 -0
- apyrobo/tests/test_lts.py +113 -0
- apyrobo/tests/test_multiturn.py +106 -0
- apyrobo/tests/test_operations/test_operations_features.py +366 -0
- apyrobo/tests/test_plan_validator.py +213 -0
- apyrobo/tests/test_plugins.py +144 -0
- apyrobo/tests/test_rbac.py +87 -0
- apyrobo/tests/test_registry.py +105 -0
- apyrobo/tests/test_retry.py +276 -0
- apyrobo/tests/test_sensors/test_pipeline_enhancements.py +82 -0
- apyrobo/tests/test_sim/test_sim_adapters.py +50 -0
- apyrobo/tests/test_tool_agent.py +81 -0
- apyrobo/tests/test_versioning.py +150 -0
- apyrobo/tests/test_vlm.py +74 -0
- apyrobo/versioning/__init__.py +13 -0
- apyrobo/versioning/changelog.py +132 -0
- apyrobo/versioning/compatibility.py +85 -0
- apyrobo/versioning/migration.py +106 -0
- apyrobo/voice.py +663 -0
- apyrobo-3.0.0.dist-info/METADATA +452 -0
- apyrobo-3.0.0.dist-info/RECORD +124 -0
- apyrobo-3.0.0.dist-info/WHEEL +5 -0
- apyrobo-3.0.0.dist-info/entry_points.txt +2 -0
- apyrobo-3.0.0.dist-info/licenses/LICENSE +189 -0
- apyrobo-3.0.0.dist-info/top_level.txt +1 -0
apyrobo/__init__.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""
|
|
2
|
+
APYROBO — Open-source AI orchestration layer for robotics.
|
|
3
|
+
|
|
4
|
+
Built on ROS 2. Model-agnostic. Hardware-agnostic.
|
|
5
|
+
|
|
6
|
+
from apyrobo import Agent, Robot, SafetyEnforcer
|
|
7
|
+
|
|
8
|
+
robot = Robot.discover("mock://turtlebot4")
|
|
9
|
+
agent = Agent(provider="rule")
|
|
10
|
+
result = agent.execute(task="deliver package to room 3", robot=robot)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from apyrobo.__version__ import __version__
|
|
14
|
+
|
|
15
|
+
from apyrobo.core.robot import Robot
|
|
16
|
+
from apyrobo.core.health import ConnectionHealth
|
|
17
|
+
from apyrobo.core.adapters import (
|
|
18
|
+
CapabilityAdapter, MockAdapter, GazeboAdapter, MQTTAdapter, HTTPAdapter,
|
|
19
|
+
list_adapters, register_adapter, register_adapter_class,
|
|
20
|
+
)
|
|
21
|
+
from apyrobo.core.schemas import RobotCapability, TaskRequest, TaskResult, AdapterState
|
|
22
|
+
from apyrobo.skills.agent import (
|
|
23
|
+
Agent, AgentProvider, RuleBasedProvider, LLMProvider,
|
|
24
|
+
ToolCallingProvider, MultiTurnProvider, ClarificationNeeded,
|
|
25
|
+
build_constrained_prompt,
|
|
26
|
+
)
|
|
27
|
+
from apyrobo.skills.skill import Skill, BUILTIN_SKILLS
|
|
28
|
+
from apyrobo.skills.decorators import skill, get_decorated_skills
|
|
29
|
+
from apyrobo.skills.executor import SkillGraph, SkillExecutor, ExecutionState, SkillTimeout
|
|
30
|
+
from apyrobo.safety.enforcer import (
|
|
31
|
+
SafetyEnforcer, SafetyPolicy, SafetyViolation, EscalationTimeout,
|
|
32
|
+
SpeedProfile, SafetyAuditEntry, FormalConstraintExporter,
|
|
33
|
+
POLICY_REGISTRY,
|
|
34
|
+
)
|
|
35
|
+
from apyrobo.safety.confidence import (
|
|
36
|
+
ConfidenceEstimator, ConfidenceReport, LowConfidenceError,
|
|
37
|
+
)
|
|
38
|
+
from apyrobo.swarm.bus import SwarmBus, SwarmMessage
|
|
39
|
+
from apyrobo.swarm.coordinator import SwarmCoordinator
|
|
40
|
+
from apyrobo.swarm.safety import SwarmSafety, ProximityViolation, DeadlockDetected
|
|
41
|
+
from apyrobo.sensors.pipeline import SensorPipeline, WorldState, SensorReading
|
|
42
|
+
from apyrobo.skills.library import SkillLibrary
|
|
43
|
+
from apyrobo.skills.package import SkillPackage
|
|
44
|
+
from apyrobo.skills.registry import SkillRegistry, PackageConflict, DependencyError
|
|
45
|
+
from apyrobo.config import ApyroboConfig
|
|
46
|
+
from apyrobo.inference.router import (
|
|
47
|
+
InferenceRouter, Urgency, CircuitState,
|
|
48
|
+
TokenBudget, PlanCache, ProviderHealth,
|
|
49
|
+
)
|
|
50
|
+
from apyrobo.observability import get_logger, trace_context, configure_logging
|
|
51
|
+
from apyrobo.persistence import StateStore
|
|
52
|
+
from apyrobo.costmap import CostmapChecker, MockCostmapChecker
|
|
53
|
+
from apyrobo.auth import AuthManager, GuardedRobot, AuthError
|
|
54
|
+
from apyrobo.task_queue import TaskQueue, QueuedTask
|
|
55
|
+
from apyrobo.operations import (
|
|
56
|
+
BatteryMonitor, MapManager, TeleoperationBridge, WebhookEmitter,
|
|
57
|
+
ScheduledTaskRunner, OperationsApiServer, FleetDashboard,
|
|
58
|
+
)
|
|
59
|
+
from apyrobo.operations import BatteryMonitor, MapManager, TeleoperationBridge, WebhookEmitter
|
|
60
|
+
from apyrobo.sim import (
|
|
61
|
+
GazeboNativeAdapter, MuJoCoAdapter, IsaacSimAdapter,
|
|
62
|
+
DomainRandomizationConfig, DomainRandomizer, RealityGapCalibrator,
|
|
63
|
+
SimToRealTransferPipeline,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
# Ensure ROS 2 adapter is registered (import triggers @register_adapter).
|
|
67
|
+
# When rclpy is missing, ros2_bridge emits a warnings.warn and _HAS_ROS2=False,
|
|
68
|
+
# so the real ROS2Adapter is not registered. We register a stub instead so that
|
|
69
|
+
# Robot.discover("ros2://...") raises a clear RuntimeError rather than a cryptic
|
|
70
|
+
# "No adapter registered for scheme 'ros2'" message.
|
|
71
|
+
import warnings as _warnings
|
|
72
|
+
_ros2_loaded = False
|
|
73
|
+
with _warnings.catch_warnings():
|
|
74
|
+
_warnings.simplefilter("ignore") # suppress ros2_bridge's own rclpy ImportWarning
|
|
75
|
+
try:
|
|
76
|
+
from apyrobo.core import ros2_bridge as _ros2_bridge # noqa: F401
|
|
77
|
+
_ros2_loaded = True
|
|
78
|
+
except Exception:
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
if not _ros2_loaded:
|
|
82
|
+
_warnings.warn(
|
|
83
|
+
"ROS 2 adapter not available (rclpy not installed). "
|
|
84
|
+
"Install inside a ROS 2 environment or use the Docker image. "
|
|
85
|
+
"Available without ROS 2: mock://, gazebo://, gazebo_native://",
|
|
86
|
+
stacklevel=2,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
from apyrobo.core.adapters import _ADAPTER_REGISTRY as _REG, CapabilityAdapter as _CA
|
|
90
|
+
|
|
91
|
+
if "ros2" not in _REG:
|
|
92
|
+
class _ROS2Unavailable(_CA):
|
|
93
|
+
"""Stub adapter that raises a helpful error when rclpy is not installed."""
|
|
94
|
+
|
|
95
|
+
def __init__(self, robot_name: str, **kwargs: object) -> None:
|
|
96
|
+
raise RuntimeError(
|
|
97
|
+
"The ros2:// adapter requires rclpy, which is only available inside "
|
|
98
|
+
"the APYROBO Docker container.\n\n"
|
|
99
|
+
"Quick fix:\n"
|
|
100
|
+
" docker compose -f docker/docker-compose.yml exec apyrobo bash\n\n"
|
|
101
|
+
"Without Docker, use mock:// for testing or gazebo:// for sim.\n"
|
|
102
|
+
"See docs/QUICKSTART.md for the full setup guide."
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
def get_capabilities(self): # type: ignore[override]
|
|
106
|
+
raise NotImplementedError
|
|
107
|
+
|
|
108
|
+
def move(self, x: float, y: float, speed=None) -> None: # type: ignore[override]
|
|
109
|
+
raise NotImplementedError
|
|
110
|
+
|
|
111
|
+
def stop(self) -> None:
|
|
112
|
+
raise NotImplementedError
|
|
113
|
+
|
|
114
|
+
_REG["ros2"] = _ROS2Unavailable
|
|
115
|
+
|
|
116
|
+
del _REG, _CA
|
|
117
|
+
|
|
118
|
+
__all__ = [
|
|
119
|
+
"__version__",
|
|
120
|
+
"Robot",
|
|
121
|
+
"Agent",
|
|
122
|
+
"skill",
|
|
123
|
+
"get_decorated_skills",
|
|
124
|
+
"SkillLibrary",
|
|
125
|
+
"RobotCapability",
|
|
126
|
+
"TaskRequest",
|
|
127
|
+
"TaskResult",
|
|
128
|
+
"AdapterState",
|
|
129
|
+
"CapabilityAdapter",
|
|
130
|
+
"MockAdapter",
|
|
131
|
+
"GazeboAdapter",
|
|
132
|
+
"MQTTAdapter",
|
|
133
|
+
"HTTPAdapter",
|
|
134
|
+
"GazeboNativeAdapter",
|
|
135
|
+
"MuJoCoAdapter",
|
|
136
|
+
"IsaacSimAdapter",
|
|
137
|
+
"list_adapters",
|
|
138
|
+
"register_adapter",
|
|
139
|
+
"register_adapter_class",
|
|
140
|
+
"Skill",
|
|
141
|
+
"BUILTIN_SKILLS",
|
|
142
|
+
"SkillGraph",
|
|
143
|
+
"SkillExecutor",
|
|
144
|
+
"SafetyEnforcer",
|
|
145
|
+
"SafetyPolicy",
|
|
146
|
+
"SafetyViolation",
|
|
147
|
+
"ScheduledTaskRunner",
|
|
148
|
+
"OperationsApiServer",
|
|
149
|
+
"FleetDashboard",
|
|
150
|
+
"CostmapChecker",
|
|
151
|
+
"MockCostmapChecker",
|
|
152
|
+
]
|
apyrobo/__main__.py
ADDED
apyrobo/__version__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Agents — stateful LLM agents for robot task orchestration.
|
|
3
|
+
|
|
4
|
+
Provides multi-turn conversation agents and tool-calling agents
|
|
5
|
+
that sit above the InferenceRouter and skills layers.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from apyrobo.agents.multiturn import ConversationHistory, ConversationMessage, MultiTurnAgent
|
|
9
|
+
from apyrobo.agents.tool_agent import SkillTool, ToolCallingAgent
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"ConversationMessage",
|
|
13
|
+
"ConversationHistory",
|
|
14
|
+
"MultiTurnAgent",
|
|
15
|
+
"SkillTool",
|
|
16
|
+
"ToolCallingAgent",
|
|
17
|
+
]
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Multi-turn Agent — stateful conversation with clarification dialogue.
|
|
3
|
+
|
|
4
|
+
Maintains conversation history and sends full context to the LLM on each
|
|
5
|
+
turn, enabling robots to ask for clarification when a task is ambiguous.
|
|
6
|
+
|
|
7
|
+
Classes:
|
|
8
|
+
ConversationMessage — a single turn (role + content + timestamp)
|
|
9
|
+
ConversationHistory — ordered list of messages with token-aware truncation
|
|
10
|
+
MultiTurnAgent — agent that answers via LLM or MockLLM fallback
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
import time
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
# Rough tokens-per-char estimate for context window truncation.
|
|
23
|
+
_CHARS_PER_TOKEN = 4
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class ConversationMessage:
|
|
28
|
+
"""A single message in a multi-turn conversation."""
|
|
29
|
+
|
|
30
|
+
role: str # "user" | "assistant" | "system"
|
|
31
|
+
content: str
|
|
32
|
+
timestamp: float = field(default_factory=time.time)
|
|
33
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
34
|
+
|
|
35
|
+
def to_dict(self) -> dict[str, Any]:
|
|
36
|
+
return {
|
|
37
|
+
"role": self.role,
|
|
38
|
+
"content": self.content,
|
|
39
|
+
"timestamp": self.timestamp,
|
|
40
|
+
"metadata": self.metadata,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ConversationHistory:
|
|
45
|
+
"""
|
|
46
|
+
Ordered history of conversation messages.
|
|
47
|
+
|
|
48
|
+
Supports token-budget truncation: get_context() drops the oldest
|
|
49
|
+
non-system messages first until the total fits within max_tokens.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(self) -> None:
|
|
53
|
+
self._messages: list[ConversationMessage] = []
|
|
54
|
+
|
|
55
|
+
def add(self, msg: ConversationMessage) -> None:
|
|
56
|
+
self._messages.append(msg)
|
|
57
|
+
|
|
58
|
+
def get_context(self, max_tokens: int = 4096) -> list[dict[str, Any]]:
|
|
59
|
+
"""
|
|
60
|
+
Return messages as LLM-compatible dicts, truncated to fit max_tokens.
|
|
61
|
+
|
|
62
|
+
System messages are always kept; oldest user/assistant messages are
|
|
63
|
+
dropped first when over budget.
|
|
64
|
+
"""
|
|
65
|
+
system_msgs = [m for m in self._messages if m.role == "system"]
|
|
66
|
+
other_msgs = [m for m in self._messages if m.role != "system"]
|
|
67
|
+
|
|
68
|
+
# Start with system messages (always included)
|
|
69
|
+
kept = list(system_msgs)
|
|
70
|
+
budget = max_tokens - sum(len(m.content) // _CHARS_PER_TOKEN for m in kept)
|
|
71
|
+
|
|
72
|
+
# Add other messages newest-first until budget exhausted
|
|
73
|
+
for msg in reversed(other_msgs):
|
|
74
|
+
cost = len(msg.content) // _CHARS_PER_TOKEN + 1
|
|
75
|
+
if budget >= cost:
|
|
76
|
+
kept.append(msg)
|
|
77
|
+
budget -= cost
|
|
78
|
+
else:
|
|
79
|
+
break
|
|
80
|
+
|
|
81
|
+
# Sort by timestamp to restore chronological order
|
|
82
|
+
kept.sort(key=lambda m: m.timestamp)
|
|
83
|
+
return [{"role": m.role, "content": m.content} for m in kept]
|
|
84
|
+
|
|
85
|
+
def clear(self) -> None:
|
|
86
|
+
self._messages.clear()
|
|
87
|
+
|
|
88
|
+
def to_dict(self) -> list[dict[str, Any]]:
|
|
89
|
+
return [m.to_dict() for m in self._messages]
|
|
90
|
+
|
|
91
|
+
def __len__(self) -> int:
|
|
92
|
+
return len(self._messages)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _mock_llm_reply(messages: list[dict[str, Any]], context: dict[str, Any] | None) -> str:
|
|
96
|
+
"""Deterministic mock LLM for use when litellm is not configured."""
|
|
97
|
+
last_user = next(
|
|
98
|
+
(m["content"] for m in reversed(messages) if m["role"] == "user"), ""
|
|
99
|
+
)
|
|
100
|
+
ctx_note = f" [context keys: {list(context)}]" if context else ""
|
|
101
|
+
return f"Mock reply to: {last_user!r}{ctx_note}"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class MultiTurnAgent:
|
|
105
|
+
"""
|
|
106
|
+
Stateful agent that maintains conversation history across turns.
|
|
107
|
+
|
|
108
|
+
Uses litellm if available and configured; falls back to MockLLM for
|
|
109
|
+
offline/test use.
|
|
110
|
+
|
|
111
|
+
Usage:
|
|
112
|
+
agent = MultiTurnAgent(system_prompt="You are a robot assistant.")
|
|
113
|
+
reply = agent.chat("navigate to the kitchen")
|
|
114
|
+
follow = agent.chat("actually, go to the living room instead")
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
def __init__(self, model: str = "gpt-4o", system_prompt: str = "") -> None:
|
|
118
|
+
self.model = model
|
|
119
|
+
self.history = ConversationHistory()
|
|
120
|
+
if system_prompt:
|
|
121
|
+
self.history.add(
|
|
122
|
+
ConversationMessage(role="system", content=system_prompt)
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
def chat(self, message: str, context: dict[str, Any] | None = None) -> str:
|
|
126
|
+
"""
|
|
127
|
+
Send a message and return the assistant's reply.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
message: User message text.
|
|
131
|
+
context: Optional dict merged into the user message metadata.
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
Assistant reply string.
|
|
135
|
+
"""
|
|
136
|
+
self.history.add(
|
|
137
|
+
ConversationMessage(role="user", content=message, metadata=context or {})
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
messages = self.history.get_context(max_tokens=4096)
|
|
141
|
+
reply = self._call_llm(messages, context)
|
|
142
|
+
|
|
143
|
+
self.history.add(ConversationMessage(role="assistant", content=reply))
|
|
144
|
+
return reply
|
|
145
|
+
|
|
146
|
+
def _call_llm(
|
|
147
|
+
self, messages: list[dict[str, Any]], context: dict[str, Any] | None
|
|
148
|
+
) -> str:
|
|
149
|
+
try:
|
|
150
|
+
import litellm # type: ignore[import]
|
|
151
|
+
response = litellm.completion(
|
|
152
|
+
model=self.model,
|
|
153
|
+
messages=messages,
|
|
154
|
+
max_tokens=512,
|
|
155
|
+
)
|
|
156
|
+
return response.choices[0].message.content or ""
|
|
157
|
+
except Exception as exc:
|
|
158
|
+
logger.debug("litellm unavailable (%s), using mock LLM", exc)
|
|
159
|
+
return _mock_llm_reply(messages, context)
|
|
160
|
+
|
|
161
|
+
def reset(self) -> None:
|
|
162
|
+
"""Clear conversation history."""
|
|
163
|
+
self.history.clear()
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tool-calling Agent — LLM that directly invokes skills via function-calling.
|
|
3
|
+
|
|
4
|
+
The agent converts a list of SkillTool definitions into LLM tool/function
|
|
5
|
+
specs, sends the task to the LLM, executes any tool calls the model
|
|
6
|
+
requests, and returns the final text answer.
|
|
7
|
+
|
|
8
|
+
Falls back to a deterministic mock when litellm is not configured.
|
|
9
|
+
|
|
10
|
+
Classes:
|
|
11
|
+
SkillTool — descriptor for one executable skill
|
|
12
|
+
ToolCallingAgent — agent that uses function-calling to run skills
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import logging
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from typing import Any, Callable
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class SkillTool:
|
|
27
|
+
"""A skill exposed to the LLM as a callable tool."""
|
|
28
|
+
|
|
29
|
+
name: str
|
|
30
|
+
description: str
|
|
31
|
+
parameters: dict[str, Any] # JSON Schema object
|
|
32
|
+
executor: Callable[..., Any] # sync function called with **params
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _tools_to_litellm(tools: list[SkillTool]) -> list[dict[str, Any]]:
|
|
36
|
+
"""Convert SkillTools to litellm-compatible tool definitions."""
|
|
37
|
+
return [
|
|
38
|
+
{
|
|
39
|
+
"type": "function",
|
|
40
|
+
"function": {
|
|
41
|
+
"name": t.name,
|
|
42
|
+
"description": t.description,
|
|
43
|
+
"parameters": t.parameters,
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
for t in tools
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _mock_run(task: str, tools: list[SkillTool]) -> str:
|
|
51
|
+
"""
|
|
52
|
+
Deterministic mock: execute the first tool with default params,
|
|
53
|
+
then return a canned summary.
|
|
54
|
+
"""
|
|
55
|
+
if not tools:
|
|
56
|
+
return f"Mock: no tools available for task {task!r}"
|
|
57
|
+
tool = tools[0]
|
|
58
|
+
# Build minimal args from required properties
|
|
59
|
+
props = tool.parameters.get("properties", {})
|
|
60
|
+
required = tool.parameters.get("required", [])
|
|
61
|
+
kwargs: dict[str, Any] = {}
|
|
62
|
+
for key in required:
|
|
63
|
+
schema = props.get(key, {})
|
|
64
|
+
t = schema.get("type", "string")
|
|
65
|
+
kwargs[key] = 0.0 if t == "number" else ("mock_value" if t == "string" else None)
|
|
66
|
+
result = tool.executor(**kwargs)
|
|
67
|
+
return f"Mock: executed {tool.name!r} → {result}"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class ToolCallingAgent:
|
|
71
|
+
"""
|
|
72
|
+
Agent that uses LLM function-calling to invoke skills.
|
|
73
|
+
|
|
74
|
+
Usage:
|
|
75
|
+
def move_robot(x: float, y: float):
|
|
76
|
+
robot.move(x, y)
|
|
77
|
+
return "moved"
|
|
78
|
+
|
|
79
|
+
tool = SkillTool(
|
|
80
|
+
name="navigate_to",
|
|
81
|
+
description="Move robot to (x, y)",
|
|
82
|
+
parameters={
|
|
83
|
+
"type": "object",
|
|
84
|
+
"properties": {"x": {"type": "number"}, "y": {"type": "number"}},
|
|
85
|
+
"required": ["x", "y"],
|
|
86
|
+
},
|
|
87
|
+
executor=move_robot,
|
|
88
|
+
)
|
|
89
|
+
agent = ToolCallingAgent(tools=[tool])
|
|
90
|
+
result = agent.run("go to position 3, 5")
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
def __init__(self, tools: list[SkillTool], model: str = "gpt-4o") -> None:
|
|
94
|
+
self.tools = tools
|
|
95
|
+
self.model = model
|
|
96
|
+
self._tool_map: dict[str, SkillTool] = {t.name: t for t in tools}
|
|
97
|
+
|
|
98
|
+
def run(self, task: str) -> str:
|
|
99
|
+
"""
|
|
100
|
+
Execute a task, invoking tools as needed.
|
|
101
|
+
|
|
102
|
+
Returns the final natural-language answer.
|
|
103
|
+
"""
|
|
104
|
+
try:
|
|
105
|
+
import litellm # type: ignore[import]
|
|
106
|
+
return self._run_with_litellm(task, litellm)
|
|
107
|
+
except Exception as exc:
|
|
108
|
+
logger.debug("litellm unavailable (%s), using mock", exc)
|
|
109
|
+
return _mock_run(task, self.tools)
|
|
110
|
+
|
|
111
|
+
def _run_with_litellm(self, task: str, litellm: Any) -> str:
|
|
112
|
+
messages: list[dict[str, Any]] = [{"role": "user", "content": task}]
|
|
113
|
+
tool_defs = _tools_to_litellm(self.tools)
|
|
114
|
+
|
|
115
|
+
for _ in range(10): # guard against infinite loops
|
|
116
|
+
response = litellm.completion(
|
|
117
|
+
model=self.model,
|
|
118
|
+
messages=messages,
|
|
119
|
+
tools=tool_defs,
|
|
120
|
+
tool_choice="auto",
|
|
121
|
+
)
|
|
122
|
+
choice = response.choices[0]
|
|
123
|
+
finish = choice.finish_reason
|
|
124
|
+
msg = choice.message
|
|
125
|
+
|
|
126
|
+
# Add assistant message to context
|
|
127
|
+
messages.append({"role": "assistant", "content": msg.content or "", "tool_calls": getattr(msg, "tool_calls", None)})
|
|
128
|
+
|
|
129
|
+
if finish == "tool_calls" and msg.tool_calls:
|
|
130
|
+
for tc in msg.tool_calls:
|
|
131
|
+
fn_name = tc.function.name
|
|
132
|
+
fn_args = json.loads(tc.function.arguments or "{}")
|
|
133
|
+
tool = self._tool_map.get(fn_name)
|
|
134
|
+
if tool is None:
|
|
135
|
+
result = f"Unknown tool: {fn_name}"
|
|
136
|
+
else:
|
|
137
|
+
try:
|
|
138
|
+
result = str(tool.executor(**fn_args))
|
|
139
|
+
except Exception as e:
|
|
140
|
+
result = f"Error: {e}"
|
|
141
|
+
messages.append({
|
|
142
|
+
"role": "tool",
|
|
143
|
+
"tool_call_id": tc.id,
|
|
144
|
+
"name": fn_name,
|
|
145
|
+
"content": result,
|
|
146
|
+
})
|
|
147
|
+
else:
|
|
148
|
+
# Final answer
|
|
149
|
+
return msg.content or ""
|
|
150
|
+
|
|
151
|
+
return "Tool-calling agent exceeded iteration limit."
|
apyrobo/api/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""
|
|
2
|
+
REST API Gateway — HTTP interface for external systems to submit tasks.
|
|
3
|
+
|
|
4
|
+
Exposes a FastAPI application with:
|
|
5
|
+
POST /tasks — submit a task
|
|
6
|
+
GET /tasks/{id} — get task status
|
|
7
|
+
GET /robots — list registered robots
|
|
8
|
+
POST /robots/{id}/skills/{skill} — execute a skill directly
|
|
9
|
+
GET /health — health check
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from apyrobo.api.app import create_app
|
|
13
|
+
|
|
14
|
+
__all__ = ["create_app"]
|
apyrobo/api/app.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""
|
|
2
|
+
REST API Gateway — FastAPI application.
|
|
3
|
+
|
|
4
|
+
Authentication:
|
|
5
|
+
All mutating endpoints require an X-API-Key header validated against
|
|
6
|
+
the APYROBO_API_KEY environment variable (or a key supplied to
|
|
7
|
+
create_app()). GET /health is public.
|
|
8
|
+
|
|
9
|
+
In-memory stores:
|
|
10
|
+
Tasks and robots are kept in plain dicts — no database required.
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
from apyrobo.api.app import create_app
|
|
14
|
+
app = create_app(api_key="secret")
|
|
15
|
+
|
|
16
|
+
# with uvicorn:
|
|
17
|
+
# uvicorn apyrobo.api.app:app
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import os
|
|
23
|
+
import time
|
|
24
|
+
import uuid
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
from fastapi import Depends, FastAPI, HTTPException, Header, status
|
|
29
|
+
from fastapi.responses import JSONResponse
|
|
30
|
+
from pydantic import BaseModel
|
|
31
|
+
_FASTAPI_AVAILABLE = True
|
|
32
|
+
except ImportError:
|
|
33
|
+
_FASTAPI_AVAILABLE = False
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
# Request / response models
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
if _FASTAPI_AVAILABLE:
|
|
41
|
+
class TaskRequest(BaseModel):
|
|
42
|
+
skill: str
|
|
43
|
+
params: dict[str, Any] = {}
|
|
44
|
+
robot_id: str = ""
|
|
45
|
+
|
|
46
|
+
class SkillRequest(BaseModel):
|
|
47
|
+
params: dict[str, Any] = {}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
# Factory
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
def create_app(api_key: str | None = None) -> "FastAPI":
|
|
55
|
+
"""
|
|
56
|
+
Create and return the FastAPI application.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
api_key: If provided, all authenticated endpoints require this key
|
|
60
|
+
in the X-API-Key header. Defaults to the APYROBO_API_KEY
|
|
61
|
+
env variable, or an empty string (no auth).
|
|
62
|
+
"""
|
|
63
|
+
if not _FASTAPI_AVAILABLE:
|
|
64
|
+
raise ImportError("fastapi and pydantic are required for the REST API gateway")
|
|
65
|
+
|
|
66
|
+
_api_key: str = api_key or os.getenv("APYROBO_API_KEY", "")
|
|
67
|
+
|
|
68
|
+
# In-memory state
|
|
69
|
+
_tasks: dict[str, dict[str, Any]] = {}
|
|
70
|
+
_robots: dict[str, dict[str, Any]] = {}
|
|
71
|
+
|
|
72
|
+
app = FastAPI(title="Apyrobo API", version="0.4.0")
|
|
73
|
+
|
|
74
|
+
# ------------------------------------------------------------------
|
|
75
|
+
# Auth dependency
|
|
76
|
+
# ------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
def require_auth(x_api_key: str = Header(default="")) -> None:
|
|
79
|
+
if _api_key and x_api_key != _api_key:
|
|
80
|
+
raise HTTPException(
|
|
81
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
82
|
+
detail="Invalid or missing X-API-Key",
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
# ------------------------------------------------------------------
|
|
86
|
+
# Health
|
|
87
|
+
# ------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
@app.get("/health")
|
|
90
|
+
def health() -> dict[str, Any]:
|
|
91
|
+
return {"status": "ok", "tasks": len(_tasks), "robots": len(_robots)}
|
|
92
|
+
|
|
93
|
+
# ------------------------------------------------------------------
|
|
94
|
+
# Tasks
|
|
95
|
+
# ------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
@app.post("/tasks", status_code=status.HTTP_201_CREATED,
|
|
98
|
+
dependencies=[Depends(require_auth)])
|
|
99
|
+
def submit_task(req: TaskRequest) -> dict[str, Any]:
|
|
100
|
+
task_id = str(uuid.uuid4())
|
|
101
|
+
task = {
|
|
102
|
+
"task_id": task_id,
|
|
103
|
+
"skill": req.skill,
|
|
104
|
+
"params": req.params,
|
|
105
|
+
"robot_id": req.robot_id,
|
|
106
|
+
"status": "queued",
|
|
107
|
+
"created_at": time.time(),
|
|
108
|
+
"updated_at": time.time(),
|
|
109
|
+
"result": None,
|
|
110
|
+
}
|
|
111
|
+
_tasks[task_id] = task
|
|
112
|
+
return task
|
|
113
|
+
|
|
114
|
+
@app.get("/tasks/{task_id}", dependencies=[Depends(require_auth)])
|
|
115
|
+
def get_task(task_id: str) -> dict[str, Any]:
|
|
116
|
+
task = _tasks.get(task_id)
|
|
117
|
+
if task is None:
|
|
118
|
+
raise HTTPException(status_code=404, detail=f"Task {task_id!r} not found")
|
|
119
|
+
return task
|
|
120
|
+
|
|
121
|
+
# ------------------------------------------------------------------
|
|
122
|
+
# Robots
|
|
123
|
+
# ------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
@app.get("/robots", dependencies=[Depends(require_auth)])
|
|
126
|
+
def list_robots() -> list[dict[str, Any]]:
|
|
127
|
+
return list(_robots.values())
|
|
128
|
+
|
|
129
|
+
@app.post("/robots/{robot_id}/skills/{skill}",
|
|
130
|
+
dependencies=[Depends(require_auth)])
|
|
131
|
+
def execute_skill(robot_id: str, skill: str, req: SkillRequest) -> dict[str, Any]:
|
|
132
|
+
# Register the robot if not yet seen
|
|
133
|
+
if robot_id not in _robots:
|
|
134
|
+
_robots[robot_id] = {
|
|
135
|
+
"robot_id": robot_id,
|
|
136
|
+
"registered_at": time.time(),
|
|
137
|
+
"capabilities": [],
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
task_id = str(uuid.uuid4())
|
|
141
|
+
task = {
|
|
142
|
+
"task_id": task_id,
|
|
143
|
+
"skill": skill,
|
|
144
|
+
"params": req.params,
|
|
145
|
+
"robot_id": robot_id,
|
|
146
|
+
"status": "queued",
|
|
147
|
+
"created_at": time.time(),
|
|
148
|
+
"updated_at": time.time(),
|
|
149
|
+
"result": None,
|
|
150
|
+
}
|
|
151
|
+
_tasks[task_id] = task
|
|
152
|
+
return {"task_id": task_id, "status": "queued"}
|
|
153
|
+
|
|
154
|
+
# Expose stores for testing
|
|
155
|
+
app.state.tasks = _tasks
|
|
156
|
+
app.state.robots = _robots
|
|
157
|
+
|
|
158
|
+
return app
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# ---------------------------------------------------------------------------
|
|
162
|
+
# Default app instance (for uvicorn apyrobo.api.app:app)
|
|
163
|
+
# ---------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
if _FASTAPI_AVAILABLE:
|
|
166
|
+
app = create_app()
|