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.
Files changed (124) hide show
  1. apyrobo/__init__.py +152 -0
  2. apyrobo/__main__.py +6 -0
  3. apyrobo/__version__.py +3 -0
  4. apyrobo/agents/__init__.py +17 -0
  5. apyrobo/agents/multiturn.py +163 -0
  6. apyrobo/agents/tool_agent.py +151 -0
  7. apyrobo/api/__init__.py +14 -0
  8. apyrobo/api/app.py +166 -0
  9. apyrobo/audit.py +244 -0
  10. apyrobo/auth.py +297 -0
  11. apyrobo/cli.py +1857 -0
  12. apyrobo/config.py +408 -0
  13. apyrobo/core/__init__.py +13 -0
  14. apyrobo/core/adapters.py +841 -0
  15. apyrobo/core/health.py +241 -0
  16. apyrobo/core/robot.py +157 -0
  17. apyrobo/core/ros2_bridge.py +798 -0
  18. apyrobo/core/schemas.py +232 -0
  19. apyrobo/costmap.py +208 -0
  20. apyrobo/dashboard.py +362 -0
  21. apyrobo/fleet/__init__.py +18 -0
  22. apyrobo/fleet/manager.py +334 -0
  23. apyrobo/fleet/multisite.py +182 -0
  24. apyrobo/hardware/__init__.py +10 -0
  25. apyrobo/hardware/autodiscovery.py +138 -0
  26. apyrobo/hardware/schema.py +144 -0
  27. apyrobo/inference/__init__.py +32 -0
  28. apyrobo/inference/edge.py +176 -0
  29. apyrobo/inference/router.py +1010 -0
  30. apyrobo/inference/vlm.py +143 -0
  31. apyrobo/lts/__init__.py +6 -0
  32. apyrobo/lts/checker.py +79 -0
  33. apyrobo/lts/policy.py +93 -0
  34. apyrobo/memory/__init__.py +357 -0
  35. apyrobo/memory/episodic.py +320 -0
  36. apyrobo/memory/plan_cache.py +400 -0
  37. apyrobo/memory/semantic.py +287 -0
  38. apyrobo/moveit.py +459 -0
  39. apyrobo/nav2.py +404 -0
  40. apyrobo/observability.py +998 -0
  41. apyrobo/operations.py +961 -0
  42. apyrobo/orchestration/__init__.py +16 -0
  43. apyrobo/orchestration/adapter.py +263 -0
  44. apyrobo/persistence.py +767 -0
  45. apyrobo/plugins/__init__.py +7 -0
  46. apyrobo/plugins/base.py +89 -0
  47. apyrobo/plugins/loader.py +144 -0
  48. apyrobo/plugins/registry.py +137 -0
  49. apyrobo/profiles/__init__.py +4 -0
  50. apyrobo/profiles/schema.py +201 -0
  51. apyrobo/registry/__init__.py +6 -0
  52. apyrobo/registry/client.py +118 -0
  53. apyrobo/registry/models.py +52 -0
  54. apyrobo/registry/server.py +186 -0
  55. apyrobo/safety/__init__.py +21 -0
  56. apyrobo/safety/confidence.py +388 -0
  57. apyrobo/safety/enforcer.py +1180 -0
  58. apyrobo/safety/verification.py +185 -0
  59. apyrobo/sensors/__init__.py +11 -0
  60. apyrobo/sensors/pipeline.py +644 -0
  61. apyrobo/sensors/ros2_subscribers.py +386 -0
  62. apyrobo/sim/__init__.py +35 -0
  63. apyrobo/sim/adapters.py +484 -0
  64. apyrobo/sim/mujoco.py +153 -0
  65. apyrobo/sim/twin.py +164 -0
  66. apyrobo/skills/__init__.py +29 -0
  67. apyrobo/skills/agent.py +1339 -0
  68. apyrobo/skills/builtins.py +79 -0
  69. apyrobo/skills/builtins_extended.py +75 -0
  70. apyrobo/skills/checkpoint.py +297 -0
  71. apyrobo/skills/compose.py +475 -0
  72. apyrobo/skills/corrections.py +217 -0
  73. apyrobo/skills/decorators.py +152 -0
  74. apyrobo/skills/demonstrations.py +423 -0
  75. apyrobo/skills/discovery.py +187 -0
  76. apyrobo/skills/executor.py +740 -0
  77. apyrobo/skills/feedback.py +160 -0
  78. apyrobo/skills/handlers.py +225 -0
  79. apyrobo/skills/library.py +204 -0
  80. apyrobo/skills/longterm.py +492 -0
  81. apyrobo/skills/package.py +435 -0
  82. apyrobo/skills/plan_validator.py +354 -0
  83. apyrobo/skills/registry.py +355 -0
  84. apyrobo/skills/replanner.py +75 -0
  85. apyrobo/skills/retry.py +286 -0
  86. apyrobo/skills/simtoreal.py +233 -0
  87. apyrobo/skills/skill.py +237 -0
  88. apyrobo/skills/verifier.py +208 -0
  89. apyrobo/swarm/__init__.py +11 -0
  90. apyrobo/swarm/bus.py +182 -0
  91. apyrobo/swarm/coordinator.py +314 -0
  92. apyrobo/swarm/ros2_bus.py +198 -0
  93. apyrobo/swarm/safety.py +276 -0
  94. apyrobo/task_queue.py +325 -0
  95. apyrobo/tests/test_api.py +110 -0
  96. apyrobo/tests/test_audit.py +103 -0
  97. apyrobo/tests/test_checkpoint.py +270 -0
  98. apyrobo/tests/test_discovery.py +96 -0
  99. apyrobo/tests/test_feedback.py +102 -0
  100. apyrobo/tests/test_fleet.py +108 -0
  101. apyrobo/tests/test_lts.py +113 -0
  102. apyrobo/tests/test_multiturn.py +106 -0
  103. apyrobo/tests/test_operations/test_operations_features.py +366 -0
  104. apyrobo/tests/test_plan_validator.py +213 -0
  105. apyrobo/tests/test_plugins.py +144 -0
  106. apyrobo/tests/test_rbac.py +87 -0
  107. apyrobo/tests/test_registry.py +105 -0
  108. apyrobo/tests/test_retry.py +276 -0
  109. apyrobo/tests/test_sensors/test_pipeline_enhancements.py +82 -0
  110. apyrobo/tests/test_sim/test_sim_adapters.py +50 -0
  111. apyrobo/tests/test_tool_agent.py +81 -0
  112. apyrobo/tests/test_versioning.py +150 -0
  113. apyrobo/tests/test_vlm.py +74 -0
  114. apyrobo/versioning/__init__.py +13 -0
  115. apyrobo/versioning/changelog.py +132 -0
  116. apyrobo/versioning/compatibility.py +85 -0
  117. apyrobo/versioning/migration.py +106 -0
  118. apyrobo/voice.py +663 -0
  119. apyrobo-3.0.0.dist-info/METADATA +452 -0
  120. apyrobo-3.0.0.dist-info/RECORD +124 -0
  121. apyrobo-3.0.0.dist-info/WHEEL +5 -0
  122. apyrobo-3.0.0.dist-info/entry_points.txt +2 -0
  123. apyrobo-3.0.0.dist-info/licenses/LICENSE +189 -0
  124. 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
@@ -0,0 +1,6 @@
1
+ """Allow running APYROBO as ``python -m apyrobo``."""
2
+
3
+ from apyrobo.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
apyrobo/__version__.py ADDED
@@ -0,0 +1,3 @@
1
+ # Single source of truth for the package version.
2
+ # Imported by apyrobo/__init__.py and read by scripts/bump_version.sh.
3
+ __version__ = "3.0.0"
@@ -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."
@@ -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()