local-operator 0.2.2__tar.gz → 0.2.4__tar.gz
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.
- {local_operator-0.2.2 → local_operator-0.2.4}/PKG-INFO +2 -2
- {local_operator-0.2.2 → local_operator-0.2.4}/README.md +1 -1
- {local_operator-0.2.2 → local_operator-0.2.4}/local_operator/agents.py +1 -1
- {local_operator-0.2.2 → local_operator-0.2.4}/local_operator/cli.py +4 -11
- {local_operator-0.2.2 → local_operator-0.2.4}/local_operator/operator.py +23 -0
- local_operator-0.2.4/local_operator/server.py +785 -0
- {local_operator-0.2.2 → local_operator-0.2.4}/local_operator.egg-info/PKG-INFO +2 -2
- {local_operator-0.2.2 → local_operator-0.2.4}/pyproject.toml +1 -1
- local_operator-0.2.2/local_operator/server.py +0 -299
- {local_operator-0.2.2 → local_operator-0.2.4}/LICENSE +0 -0
- {local_operator-0.2.2 → local_operator-0.2.4}/local_operator/__init__.py +0 -0
- {local_operator-0.2.2 → local_operator-0.2.4}/local_operator/config.py +0 -0
- {local_operator-0.2.2 → local_operator-0.2.4}/local_operator/console.py +0 -0
- {local_operator-0.2.2 → local_operator-0.2.4}/local_operator/credentials.py +0 -0
- {local_operator-0.2.2 → local_operator-0.2.4}/local_operator/executor.py +0 -0
- {local_operator-0.2.2 → local_operator-0.2.4}/local_operator/mocks.py +0 -0
- {local_operator-0.2.2 → local_operator-0.2.4}/local_operator/model.py +0 -0
- {local_operator-0.2.2 → local_operator-0.2.4}/local_operator/prompts.py +0 -0
- {local_operator-0.2.2 → local_operator-0.2.4}/local_operator/tools.py +0 -0
- {local_operator-0.2.2 → local_operator-0.2.4}/local_operator/types.py +0 -0
- {local_operator-0.2.2 → local_operator-0.2.4}/local_operator.egg-info/SOURCES.txt +0 -0
- {local_operator-0.2.2 → local_operator-0.2.4}/local_operator.egg-info/dependency_links.txt +0 -0
- {local_operator-0.2.2 → local_operator-0.2.4}/local_operator.egg-info/entry_points.txt +0 -0
- {local_operator-0.2.2 → local_operator-0.2.4}/local_operator.egg-info/requires.txt +0 -0
- {local_operator-0.2.2 → local_operator-0.2.4}/local_operator.egg-info/top_level.txt +0 -0
- {local_operator-0.2.2 → local_operator-0.2.4}/setup.cfg +0 -0
- {local_operator-0.2.2 → local_operator-0.2.4}/setup.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.2
|
|
2
2
|
Name: local-operator
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.4
|
|
4
4
|
Summary: A Python-based agent for local command execution
|
|
5
5
|
Author-email: Damian Tran <damianvtran@gmail.com>
|
|
6
6
|
License: MIT License
|
|
@@ -209,7 +209,7 @@ local-operator --agent "My Agent"
|
|
|
209
209
|
or
|
|
210
210
|
|
|
211
211
|
```bash
|
|
212
|
-
local-operator --
|
|
212
|
+
local-operator --hosting openai --model gpt-4o exec "Make a new file called test.txt and write Hello World in it" --agent "My Agent"
|
|
213
213
|
```
|
|
214
214
|
|
|
215
215
|
## Configuration
|
|
@@ -145,7 +145,7 @@ local-operator --agent "My Agent"
|
|
|
145
145
|
or
|
|
146
146
|
|
|
147
147
|
```bash
|
|
148
|
-
local-operator --
|
|
148
|
+
local-operator --hosting openai --model gpt-4o exec "Make a new file called test.txt and write Hello World in it" --agent "My Agent"
|
|
149
149
|
```
|
|
150
150
|
|
|
151
151
|
## Configuration
|
|
@@ -163,7 +163,7 @@ class AgentRegistry:
|
|
|
163
163
|
|
|
164
164
|
return agent_metadata
|
|
165
165
|
|
|
166
|
-
def
|
|
166
|
+
def update_agent(self, agent_id: str, updated_metadata: AgentEditFields) -> None:
|
|
167
167
|
"""
|
|
168
168
|
Edit an existing agent's metadata.
|
|
169
169
|
|
|
@@ -29,12 +29,7 @@ from local_operator.config import ConfigManager
|
|
|
29
29
|
from local_operator.credentials import CredentialManager
|
|
30
30
|
from local_operator.executor import LocalCodeExecutor
|
|
31
31
|
from local_operator.model import configure_model
|
|
32
|
-
from local_operator.operator import
|
|
33
|
-
ConversationRole,
|
|
34
|
-
Operator,
|
|
35
|
-
OperatorType,
|
|
36
|
-
create_system_prompt,
|
|
37
|
-
)
|
|
32
|
+
from local_operator.operator import Operator, OperatorType
|
|
38
33
|
|
|
39
34
|
CLI_DESCRIPTION = """
|
|
40
35
|
Local Operator - An environment for agentic AI models to perform tasks on the local device.
|
|
@@ -63,10 +58,12 @@ def build_cli_parser() -> argparse.ArgumentParser:
|
|
|
63
58
|
help="Enable debug mode for verbose output",
|
|
64
59
|
)
|
|
65
60
|
parent_parser.add_argument(
|
|
61
|
+
"--agent",
|
|
66
62
|
"--agent-name",
|
|
67
63
|
type=str,
|
|
68
64
|
help="Name of the agent to use for this session. If not provided, the default"
|
|
69
65
|
" agent will be used which does not persist its session.",
|
|
66
|
+
dest="agent_name",
|
|
70
67
|
)
|
|
71
68
|
parent_parser.add_argument(
|
|
72
69
|
"--train",
|
|
@@ -412,11 +409,7 @@ def main() -> int:
|
|
|
412
409
|
|
|
413
410
|
# Start the async chat interface or execute single command
|
|
414
411
|
if args.subcommand == "exec":
|
|
415
|
-
|
|
416
|
-
operator.executor.conversation_history = [
|
|
417
|
-
{"role": ConversationRole.SYSTEM.value, "content": system_prompt}
|
|
418
|
-
]
|
|
419
|
-
message = asyncio.run(operator.handle_user_input(args.command))
|
|
412
|
+
message = asyncio.run(operator.execute_single_command(args.command))
|
|
420
413
|
if message:
|
|
421
414
|
print(message.response)
|
|
422
415
|
return 0
|
|
@@ -292,6 +292,29 @@ class Operator:
|
|
|
292
292
|
print(f"\033[1;35m│ {line}\033[0m")
|
|
293
293
|
print("\033[1;35m╰──────────────────────────────────────────────────\033[0m\n")
|
|
294
294
|
|
|
295
|
+
async def execute_single_command(self, command: str) -> ResponseJsonSchema | None:
|
|
296
|
+
"""Execute a single command in non-interactive mode.
|
|
297
|
+
|
|
298
|
+
This method is used for one-off command execution rather than interactive chat.
|
|
299
|
+
It initializes a fresh conversation history (if not already initialized),
|
|
300
|
+
processes the command through the language model, and returns the result.
|
|
301
|
+
|
|
302
|
+
Args:
|
|
303
|
+
command (str): The command/instruction to execute
|
|
304
|
+
|
|
305
|
+
Returns:
|
|
306
|
+
ResponseJsonSchema | None: The processed response from the language model,
|
|
307
|
+
or None if no valid response was generated
|
|
308
|
+
"""
|
|
309
|
+
try:
|
|
310
|
+
self.executor.initialize_conversation_history()
|
|
311
|
+
except ExecutorInitError:
|
|
312
|
+
# Conversation history already initialized
|
|
313
|
+
pass
|
|
314
|
+
|
|
315
|
+
result = await self.handle_user_input(command)
|
|
316
|
+
return result
|
|
317
|
+
|
|
295
318
|
async def chat(self) -> None:
|
|
296
319
|
"""Run the interactive chat interface with code execution capabilities.
|
|
297
320
|
|
|
@@ -0,0 +1,785 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FastAPI server implementation for Local Operator API.
|
|
3
|
+
|
|
4
|
+
Provides REST endpoints for interacting with the Local Operator agent
|
|
5
|
+
through HTTP requests instead of CLI.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
from contextlib import asynccontextmanager
|
|
10
|
+
from importlib.metadata import version
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Dict, List, Optional, cast
|
|
13
|
+
|
|
14
|
+
from fastapi import Body, FastAPI, HTTPException
|
|
15
|
+
from fastapi import Path as FPath
|
|
16
|
+
from fastapi import Query
|
|
17
|
+
from pydantic import BaseModel, Field
|
|
18
|
+
from tiktoken import encoding_for_model
|
|
19
|
+
|
|
20
|
+
from local_operator.agents import AgentEditFields, AgentRegistry
|
|
21
|
+
from local_operator.config import ConfigManager
|
|
22
|
+
from local_operator.credentials import CredentialManager
|
|
23
|
+
from local_operator.executor import LocalCodeExecutor
|
|
24
|
+
from local_operator.model import configure_model
|
|
25
|
+
from local_operator.operator import Operator, OperatorType
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger("local_operator.server")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class HealthCheckResponse(BaseModel):
|
|
31
|
+
"""Response from health check endpoint.
|
|
32
|
+
|
|
33
|
+
Attributes:
|
|
34
|
+
status: HTTP status code
|
|
35
|
+
message: Health check message
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
status: int
|
|
39
|
+
message: str
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ChatOptions(BaseModel):
|
|
43
|
+
"""Options for controlling the chat generation.
|
|
44
|
+
|
|
45
|
+
Attributes:
|
|
46
|
+
temperature: Controls randomness in responses. Higher values like 0.8 make output more
|
|
47
|
+
random, while lower values like 0.2 make it more focused and deterministic.
|
|
48
|
+
Default: 0.8
|
|
49
|
+
top_p: Controls cumulative probability of tokens to sample from. Higher values (0.95) keep
|
|
50
|
+
more options, lower values (0.1) are more selective. Default: 0.9
|
|
51
|
+
top_k: Limits tokens to sample from at each step. Lower values (10) are more selective,
|
|
52
|
+
higher values (100) allow more variety. Default: 40
|
|
53
|
+
max_tokens: Maximum tokens to generate. Model may generate fewer if response completes
|
|
54
|
+
before reaching limit. Default: 4096
|
|
55
|
+
stop: List of strings that will stop generation when encountered. Default: None
|
|
56
|
+
frequency_penalty: Reduces repetition by lowering likelihood of repeated tokens.
|
|
57
|
+
Range from -2.0 to 2.0. Default: 0.0
|
|
58
|
+
presence_penalty: Increases diversity by lowering likelihood of prompt tokens.
|
|
59
|
+
Range from -2.0 to 2.0. Default: 0.0
|
|
60
|
+
seed: Random number seed for deterministic generation. Default: None
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
temperature: Optional[float] = Field(None, ge=0.0, le=1.0)
|
|
64
|
+
top_p: Optional[float] = Field(None, ge=0.0, le=1.0)
|
|
65
|
+
top_k: Optional[int] = None
|
|
66
|
+
max_tokens: Optional[int] = None
|
|
67
|
+
stop: Optional[List[str]] = None
|
|
68
|
+
frequency_penalty: Optional[float] = None
|
|
69
|
+
presence_penalty: Optional[float] = None
|
|
70
|
+
seed: Optional[int] = None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class ChatMessage(BaseModel):
|
|
74
|
+
"""A single message in the chat conversation.
|
|
75
|
+
|
|
76
|
+
Attributes:
|
|
77
|
+
role: The role of who sent the message - "system", "user", or "assistant"
|
|
78
|
+
content: The actual text content of the message
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
role: str
|
|
82
|
+
content: str
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class ChatRequest(BaseModel):
|
|
86
|
+
"""Request body for chat generation endpoint.
|
|
87
|
+
|
|
88
|
+
Attributes:
|
|
89
|
+
hosting: Name of the hosting service to use for generation
|
|
90
|
+
model: Name of the model to use for generation
|
|
91
|
+
prompt: The prompt to generate a response for
|
|
92
|
+
stream: Whether to stream the response token by token. Default: False
|
|
93
|
+
context: Optional list of previous messages for context
|
|
94
|
+
options: Optional generation parameters to override defaults
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
hosting: str
|
|
98
|
+
model: str
|
|
99
|
+
prompt: str
|
|
100
|
+
stream: bool = False
|
|
101
|
+
context: Optional[List[ChatMessage]] = None
|
|
102
|
+
options: Optional[ChatOptions] = None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class ChatStats(BaseModel):
|
|
106
|
+
"""Statistics about token usage for the chat request.
|
|
107
|
+
|
|
108
|
+
Attributes:
|
|
109
|
+
total_tokens: Total number of tokens used in prompt and completion
|
|
110
|
+
prompt_tokens: Number of tokens in the prompt
|
|
111
|
+
completion_tokens: Number of tokens in the completion
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
total_tokens: int
|
|
115
|
+
prompt_tokens: int
|
|
116
|
+
completion_tokens: int
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class ChatResponse(BaseModel):
|
|
120
|
+
"""Response from chat generation endpoint.
|
|
121
|
+
|
|
122
|
+
Attributes:
|
|
123
|
+
response: The generated text response
|
|
124
|
+
context: List of all messages including the new response
|
|
125
|
+
stats: Token usage statistics
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
response: str
|
|
129
|
+
context: List[ChatMessage]
|
|
130
|
+
stats: ChatStats
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class CRUDResponse(BaseModel):
|
|
134
|
+
"""
|
|
135
|
+
Standard response schema for CRUD operations.
|
|
136
|
+
Attributes:
|
|
137
|
+
status: HTTP status code
|
|
138
|
+
message: Outcome message of the operation
|
|
139
|
+
result: The resulting data, which can be an object, paginated list, or empty.
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
status: int
|
|
143
|
+
message: str
|
|
144
|
+
result: Optional[Dict[str, Any]] = None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class Agent(BaseModel):
|
|
148
|
+
"""Representation of an Agent."""
|
|
149
|
+
|
|
150
|
+
id: str = Field(..., description="Unique identifier for the agent")
|
|
151
|
+
name: str = Field(..., description="Name of the agent")
|
|
152
|
+
description: Optional[str] = Field(None, description="Description of the agent")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class AgentCreate(BaseModel):
|
|
156
|
+
"""Data required to create a new agent."""
|
|
157
|
+
|
|
158
|
+
name: str = Field(..., description="Name of the agent")
|
|
159
|
+
description: Optional[str] = Field(None, description="Description of the agent")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class AgentUpdate(BaseModel):
|
|
163
|
+
"""Data for updating an existing agent."""
|
|
164
|
+
|
|
165
|
+
name: Optional[str] = Field(None, description="Updated name of the agent")
|
|
166
|
+
description: Optional[str] = Field(None, description="Updated description of the agent")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class AgentListResult(BaseModel):
|
|
170
|
+
"""Paginated list result for agents."""
|
|
171
|
+
|
|
172
|
+
total: int = Field(..., description="Total number of agents")
|
|
173
|
+
page: int = Field(..., description="Current page number")
|
|
174
|
+
per_page: int = Field(..., description="Number of agents per page")
|
|
175
|
+
agents: List[Agent] = Field(..., description="List of agents")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@asynccontextmanager
|
|
179
|
+
async def lifespan(app: FastAPI):
|
|
180
|
+
# Initialize on startup by setting up the credential and config managers
|
|
181
|
+
config_dir = Path.home() / ".local-operator"
|
|
182
|
+
agents_dir = config_dir / "agents"
|
|
183
|
+
app.state.credential_manager = CredentialManager(config_dir=config_dir)
|
|
184
|
+
app.state.config_manager = ConfigManager(config_dir=config_dir)
|
|
185
|
+
app.state.agent_registry = AgentRegistry(config_dir=agents_dir)
|
|
186
|
+
yield
|
|
187
|
+
# Clean up on shutdown
|
|
188
|
+
app.state.credential_manager = None
|
|
189
|
+
app.state.config_manager = None
|
|
190
|
+
app.state.agent_registry = None
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
app = FastAPI(
|
|
194
|
+
title="Local Operator API",
|
|
195
|
+
description="REST API interface for Local Operator agent",
|
|
196
|
+
version=version("local-operator"),
|
|
197
|
+
lifespan=lifespan,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def create_operator(request_hosting: str, request_model: str) -> Operator:
|
|
202
|
+
"""Create a LocalCodeExecutor for a single chat request using the app state managers
|
|
203
|
+
and the hosting/model provided in the request."""
|
|
204
|
+
credential_manager = getattr(app.state, "credential_manager", None)
|
|
205
|
+
config_manager = getattr(app.state, "config_manager", None)
|
|
206
|
+
agent_registry = getattr(app.state, "agent_registry", None)
|
|
207
|
+
if credential_manager is None or config_manager is None or agent_registry is None:
|
|
208
|
+
raise HTTPException(status_code=500, detail="Server configuration not initialized")
|
|
209
|
+
agent_registry = cast(AgentRegistry, agent_registry)
|
|
210
|
+
|
|
211
|
+
if not request_hosting:
|
|
212
|
+
raise ValueError("Hosting is not set")
|
|
213
|
+
|
|
214
|
+
model_instance = configure_model(
|
|
215
|
+
credential_manager=credential_manager,
|
|
216
|
+
hosting=request_hosting,
|
|
217
|
+
model=request_model,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
if not model_instance:
|
|
221
|
+
raise ValueError("No model instance configured")
|
|
222
|
+
|
|
223
|
+
executor = LocalCodeExecutor(
|
|
224
|
+
model=model_instance,
|
|
225
|
+
max_conversation_history=100,
|
|
226
|
+
detail_conversation_length=10,
|
|
227
|
+
can_prompt_user=False,
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
return Operator(
|
|
231
|
+
executor=executor,
|
|
232
|
+
credential_manager=credential_manager,
|
|
233
|
+
model_instance=executor.model,
|
|
234
|
+
config_manager=config_manager,
|
|
235
|
+
type=OperatorType.SERVER,
|
|
236
|
+
agent_registry=agent_registry,
|
|
237
|
+
current_agent=None,
|
|
238
|
+
training_mode=False,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@app.post(
|
|
243
|
+
"/v1/chat",
|
|
244
|
+
response_model=ChatResponse,
|
|
245
|
+
summary="Process chat request",
|
|
246
|
+
description="Accepts a prompt and optional context/configuration, returns the model response "
|
|
247
|
+
"and conversation history.",
|
|
248
|
+
tags=["Chat"],
|
|
249
|
+
openapi_extra={
|
|
250
|
+
"requestBody": {
|
|
251
|
+
"content": {
|
|
252
|
+
"application/json": {
|
|
253
|
+
"examples": {
|
|
254
|
+
"example": {
|
|
255
|
+
"summary": "Example Request",
|
|
256
|
+
"value": {
|
|
257
|
+
"prompt": "Print 'Hello, world!'",
|
|
258
|
+
"hosting": "openai",
|
|
259
|
+
"model": "gpt-4o",
|
|
260
|
+
"context": [],
|
|
261
|
+
"options": {"temperature": 0.7, "top_p": 0.9},
|
|
262
|
+
},
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
},
|
|
269
|
+
)
|
|
270
|
+
async def chat_endpoint(request: ChatRequest):
|
|
271
|
+
"""
|
|
272
|
+
Process a chat request and return the response with context.
|
|
273
|
+
|
|
274
|
+
The endpoint accepts a JSON payload containing the prompt, hosting, model selection, and
|
|
275
|
+
optional parameters.
|
|
276
|
+
---
|
|
277
|
+
responses:
|
|
278
|
+
200:
|
|
279
|
+
description: Successful response containing the model output and conversation history.
|
|
280
|
+
500:
|
|
281
|
+
description: Internal Server Error
|
|
282
|
+
"""
|
|
283
|
+
try:
|
|
284
|
+
# Create a new executor for this request using the provided hosting and model
|
|
285
|
+
operator = create_operator(request.hosting, request.model)
|
|
286
|
+
|
|
287
|
+
if request.context and len(request.context) > 0:
|
|
288
|
+
# Override the default system prompt with the provided context
|
|
289
|
+
conversation_history = [
|
|
290
|
+
{"role": msg.role, "content": msg.content} for msg in request.context
|
|
291
|
+
]
|
|
292
|
+
operator.executor.initialize_conversation_history(conversation_history)
|
|
293
|
+
else:
|
|
294
|
+
operator.executor.initialize_conversation_history()
|
|
295
|
+
|
|
296
|
+
# Configure model options if provided
|
|
297
|
+
if request.options:
|
|
298
|
+
temperature = request.options.temperature or operator.executor.model.temperature
|
|
299
|
+
if temperature is not None:
|
|
300
|
+
operator.model.temperature = temperature
|
|
301
|
+
operator.model.top_p = request.options.top_p or operator.executor.model.top_p
|
|
302
|
+
|
|
303
|
+
response_json = await operator.handle_user_input(request.prompt)
|
|
304
|
+
if response_json is not None:
|
|
305
|
+
response_content = response_json.response
|
|
306
|
+
else:
|
|
307
|
+
response_content = ""
|
|
308
|
+
|
|
309
|
+
# Calculate token stats using tiktoken
|
|
310
|
+
tokenizer = None
|
|
311
|
+
try:
|
|
312
|
+
tokenizer = encoding_for_model(request.model)
|
|
313
|
+
except Exception:
|
|
314
|
+
tokenizer = encoding_for_model("gpt-4o")
|
|
315
|
+
|
|
316
|
+
prompt_tokens = sum(
|
|
317
|
+
len(tokenizer.encode(msg["content"])) for msg in operator.executor.conversation_history
|
|
318
|
+
)
|
|
319
|
+
completion_tokens = len(tokenizer.encode(response_content))
|
|
320
|
+
total_tokens = prompt_tokens + completion_tokens
|
|
321
|
+
|
|
322
|
+
return ChatResponse(
|
|
323
|
+
response=response_content,
|
|
324
|
+
context=[
|
|
325
|
+
ChatMessage(role=msg["role"], content=msg["content"])
|
|
326
|
+
for msg in operator.executor.conversation_history
|
|
327
|
+
],
|
|
328
|
+
stats=ChatStats(
|
|
329
|
+
total_tokens=total_tokens,
|
|
330
|
+
prompt_tokens=prompt_tokens,
|
|
331
|
+
completion_tokens=completion_tokens,
|
|
332
|
+
),
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
except Exception:
|
|
336
|
+
logger.exception("Unexpected error while processing chat request")
|
|
337
|
+
raise HTTPException(status_code=500, detail="Internal Server Error")
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
@app.post(
|
|
341
|
+
"/v1/chat/agents/{agent_id}",
|
|
342
|
+
response_model=ChatResponse,
|
|
343
|
+
summary="Process chat request using a specific agent",
|
|
344
|
+
description=(
|
|
345
|
+
"Accepts a prompt and optional context/configuration, retrieves the specified "
|
|
346
|
+
"agent from the registry, applies it to the operator and executor, and returns the "
|
|
347
|
+
"model response and conversation history."
|
|
348
|
+
),
|
|
349
|
+
tags=["Chat"],
|
|
350
|
+
openapi_extra={
|
|
351
|
+
"requestBody": {
|
|
352
|
+
"content": {
|
|
353
|
+
"application/json": {
|
|
354
|
+
"examples": {
|
|
355
|
+
"example": {
|
|
356
|
+
"summary": "Example Request with Agent",
|
|
357
|
+
"value": {
|
|
358
|
+
"prompt": "How do I implement a binary search in Python?",
|
|
359
|
+
"hosting": "openai",
|
|
360
|
+
"model": "gpt-4o",
|
|
361
|
+
"context": [],
|
|
362
|
+
"options": {"temperature": 0.7, "top_p": 0.9},
|
|
363
|
+
},
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
},
|
|
369
|
+
},
|
|
370
|
+
)
|
|
371
|
+
async def chat_with_agent(
|
|
372
|
+
agent_id: str = FPath(
|
|
373
|
+
..., description="ID of the agent to use for the chat", example="agent123"
|
|
374
|
+
),
|
|
375
|
+
request: ChatRequest = Body(...),
|
|
376
|
+
):
|
|
377
|
+
"""
|
|
378
|
+
Process a chat request using a specific agent from the registry and return the response with
|
|
379
|
+
context. The specified agent is applied to both the operator and executor.
|
|
380
|
+
"""
|
|
381
|
+
try:
|
|
382
|
+
# Retrieve the agent registry from app state
|
|
383
|
+
agent_registry = getattr(app.state, "agent_registry", None)
|
|
384
|
+
if agent_registry is None:
|
|
385
|
+
raise HTTPException(status_code=500, detail="Agent registry not initialized")
|
|
386
|
+
agent_registry = cast(AgentRegistry, agent_registry)
|
|
387
|
+
|
|
388
|
+
# Retrieve the specific agent from the registry
|
|
389
|
+
agent_obj = agent_registry.get_agent(agent_id)
|
|
390
|
+
if not agent_obj:
|
|
391
|
+
raise HTTPException(status_code=404, detail="Agent not found")
|
|
392
|
+
|
|
393
|
+
# Create a new executor for this request using the provided hosting and model
|
|
394
|
+
operator = create_operator(request.hosting, request.model)
|
|
395
|
+
|
|
396
|
+
# Apply the retrieved agent to the operator and executor
|
|
397
|
+
operator.current_agent = agent_obj
|
|
398
|
+
setattr(operator.executor, "current_agent", agent_obj)
|
|
399
|
+
|
|
400
|
+
if request.context and len(request.context) > 0:
|
|
401
|
+
# Override the default system prompt with the provided context
|
|
402
|
+
conversation_history = [
|
|
403
|
+
{"role": msg.role, "content": msg.content} for msg in request.context
|
|
404
|
+
]
|
|
405
|
+
operator.executor.initialize_conversation_history(conversation_history)
|
|
406
|
+
else:
|
|
407
|
+
operator.executor.initialize_conversation_history()
|
|
408
|
+
|
|
409
|
+
# Configure model options if provided
|
|
410
|
+
if request.options:
|
|
411
|
+
temperature = request.options.temperature or operator.executor.model.temperature
|
|
412
|
+
if temperature is not None:
|
|
413
|
+
operator.model.temperature = temperature
|
|
414
|
+
operator.model.top_p = request.options.top_p or operator.executor.model.top_p
|
|
415
|
+
|
|
416
|
+
response_json = await operator.handle_user_input(request.prompt)
|
|
417
|
+
response_content = response_json.response if response_json is not None else ""
|
|
418
|
+
|
|
419
|
+
# Calculate token stats using tiktoken
|
|
420
|
+
tokenizer = None
|
|
421
|
+
try:
|
|
422
|
+
tokenizer = encoding_for_model(request.model)
|
|
423
|
+
except Exception:
|
|
424
|
+
tokenizer = encoding_for_model("gpt-4o")
|
|
425
|
+
|
|
426
|
+
prompt_tokens = sum(
|
|
427
|
+
len(tokenizer.encode(msg["content"])) for msg in operator.executor.conversation_history
|
|
428
|
+
)
|
|
429
|
+
completion_tokens = len(tokenizer.encode(response_content))
|
|
430
|
+
total_tokens = prompt_tokens + completion_tokens
|
|
431
|
+
|
|
432
|
+
return ChatResponse(
|
|
433
|
+
response=response_content,
|
|
434
|
+
context=[
|
|
435
|
+
ChatMessage(role=msg["role"], content=msg["content"])
|
|
436
|
+
for msg in operator.executor.conversation_history
|
|
437
|
+
],
|
|
438
|
+
stats=ChatStats(
|
|
439
|
+
total_tokens=total_tokens,
|
|
440
|
+
prompt_tokens=prompt_tokens,
|
|
441
|
+
completion_tokens=completion_tokens,
|
|
442
|
+
),
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
except HTTPException:
|
|
446
|
+
# Re-raise HTTP exceptions to preserve their status code and detail
|
|
447
|
+
raise
|
|
448
|
+
except Exception:
|
|
449
|
+
logger.exception("Unexpected error while processing chat request with agent")
|
|
450
|
+
raise HTTPException(status_code=500, detail="Internal Server Error")
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
@app.get(
|
|
454
|
+
"/v1/agents",
|
|
455
|
+
response_model=CRUDResponse,
|
|
456
|
+
summary="List agents",
|
|
457
|
+
description="Retrieve a paginated list of agents with their details.",
|
|
458
|
+
tags=["Agents"],
|
|
459
|
+
openapi_extra={
|
|
460
|
+
"responses": {
|
|
461
|
+
"200": {
|
|
462
|
+
"description": "Agents list retrieved successfully",
|
|
463
|
+
"content": {
|
|
464
|
+
"application/json": {
|
|
465
|
+
"example": {
|
|
466
|
+
"status": 200,
|
|
467
|
+
"message": "Agents retrieved successfully",
|
|
468
|
+
"result": {
|
|
469
|
+
"total": 20,
|
|
470
|
+
"page": 1,
|
|
471
|
+
"per_page": 10,
|
|
472
|
+
"agents": [
|
|
473
|
+
{
|
|
474
|
+
"id": "agent1",
|
|
475
|
+
"name": "Agent One",
|
|
476
|
+
"description": "First test agent",
|
|
477
|
+
}
|
|
478
|
+
],
|
|
479
|
+
},
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
},
|
|
483
|
+
}
|
|
484
|
+
},
|
|
485
|
+
},
|
|
486
|
+
)
|
|
487
|
+
async def list_agents(
|
|
488
|
+
page: int = Query(1, ge=1, description="Page number"),
|
|
489
|
+
per_page: int = Query(10, ge=1, description="Number of agents per page"),
|
|
490
|
+
):
|
|
491
|
+
"""
|
|
492
|
+
Retrieve a paginated list of agents.
|
|
493
|
+
"""
|
|
494
|
+
agent_registry = getattr(app.state, "agent_registry", None)
|
|
495
|
+
if agent_registry is None:
|
|
496
|
+
raise HTTPException(status_code=500, detail="Agent registry not initialized")
|
|
497
|
+
agent_registry = cast(AgentRegistry, agent_registry)
|
|
498
|
+
|
|
499
|
+
try:
|
|
500
|
+
agents_list = agent_registry.list_agents()
|
|
501
|
+
except Exception as e:
|
|
502
|
+
logger.exception("Error retrieving agents")
|
|
503
|
+
raise HTTPException(status_code=500, detail=f"Error retrieving agents: {e}")
|
|
504
|
+
|
|
505
|
+
total = len(agents_list)
|
|
506
|
+
start_idx = (page - 1) * per_page
|
|
507
|
+
end_idx = start_idx + per_page
|
|
508
|
+
paginated = agents_list[start_idx:end_idx]
|
|
509
|
+
agents_serialized = [
|
|
510
|
+
agent.model_dump() if hasattr(agent, "model_dump") else agent for agent in paginated
|
|
511
|
+
]
|
|
512
|
+
|
|
513
|
+
return CRUDResponse(
|
|
514
|
+
status=200,
|
|
515
|
+
message="Agents retrieved successfully",
|
|
516
|
+
result={
|
|
517
|
+
"total": total,
|
|
518
|
+
"page": page,
|
|
519
|
+
"per_page": per_page,
|
|
520
|
+
"agents": cast(Dict[str, Any], {"agents": agents_serialized}),
|
|
521
|
+
},
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
@app.post(
|
|
526
|
+
"/v1/agents",
|
|
527
|
+
response_model=CRUDResponse,
|
|
528
|
+
summary="Create a new agent",
|
|
529
|
+
description="Create a new agent with the provided details.",
|
|
530
|
+
tags=["Agents"],
|
|
531
|
+
openapi_extra={
|
|
532
|
+
"requestBody": {
|
|
533
|
+
"content": {
|
|
534
|
+
"application/json": {
|
|
535
|
+
"examples": {
|
|
536
|
+
"example": {
|
|
537
|
+
"summary": "Create Agent Example",
|
|
538
|
+
"value": {"name": "New Agent", "description": "A newly created agent"},
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
},
|
|
544
|
+
"responses": {
|
|
545
|
+
"201": {
|
|
546
|
+
"description": "Agent created successfully",
|
|
547
|
+
"content": {
|
|
548
|
+
"application/json": {
|
|
549
|
+
"example": {
|
|
550
|
+
"status": 201,
|
|
551
|
+
"message": "Agent created successfully",
|
|
552
|
+
"result": {
|
|
553
|
+
"id": "agent123",
|
|
554
|
+
"name": "New Agent",
|
|
555
|
+
"description": "A newly created agent",
|
|
556
|
+
},
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
},
|
|
560
|
+
}
|
|
561
|
+
},
|
|
562
|
+
},
|
|
563
|
+
)
|
|
564
|
+
async def create_agent(agent: AgentCreate = Body(...)):
|
|
565
|
+
"""
|
|
566
|
+
Create a new agent.
|
|
567
|
+
"""
|
|
568
|
+
agent_registry = getattr(app.state, "agent_registry", None)
|
|
569
|
+
if agent_registry is None:
|
|
570
|
+
raise HTTPException(status_code=500, detail="Agent registry not initialized")
|
|
571
|
+
agent_registry = cast(AgentRegistry, agent_registry)
|
|
572
|
+
|
|
573
|
+
try:
|
|
574
|
+
new_agent = agent_registry.create_agent(
|
|
575
|
+
agent_edit_metadata=cast(AgentEditFields, agent.dict())
|
|
576
|
+
)
|
|
577
|
+
except Exception as e:
|
|
578
|
+
logger.exception("Error creating agent")
|
|
579
|
+
raise HTTPException(status_code=400, detail=f"Failed to create agent: {e}")
|
|
580
|
+
|
|
581
|
+
new_agent_serialized = new_agent.dict() if hasattr(new_agent, "dict") else new_agent
|
|
582
|
+
|
|
583
|
+
return CRUDResponse(
|
|
584
|
+
status=201,
|
|
585
|
+
message="Agent created successfully",
|
|
586
|
+
result=cast(Dict[str, Any], new_agent_serialized),
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
@app.get(
|
|
591
|
+
"/v1/agents/{agent_id}",
|
|
592
|
+
response_model=CRUDResponse,
|
|
593
|
+
summary="Retrieve an agent",
|
|
594
|
+
description="Retrieve details for an agent by its ID.",
|
|
595
|
+
tags=["Agents"],
|
|
596
|
+
openapi_extra={
|
|
597
|
+
"responses": {
|
|
598
|
+
"200": {
|
|
599
|
+
"description": "Agent retrieved successfully",
|
|
600
|
+
"content": {
|
|
601
|
+
"application/json": {
|
|
602
|
+
"example": {
|
|
603
|
+
"status": 200,
|
|
604
|
+
"message": "Agent retrieved successfully",
|
|
605
|
+
"result": {
|
|
606
|
+
"id": "agent123",
|
|
607
|
+
"name": "New Agent",
|
|
608
|
+
"description": "A newly created agent",
|
|
609
|
+
},
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
},
|
|
613
|
+
}
|
|
614
|
+
},
|
|
615
|
+
},
|
|
616
|
+
)
|
|
617
|
+
async def get_agent(
|
|
618
|
+
agent_id: str = FPath(..., description="ID of the agent to retrieve", example="agent123")
|
|
619
|
+
):
|
|
620
|
+
"""
|
|
621
|
+
Retrieve an agent by ID.
|
|
622
|
+
"""
|
|
623
|
+
agent_registry = getattr(app.state, "agent_registry", None)
|
|
624
|
+
if agent_registry is None:
|
|
625
|
+
raise HTTPException(status_code=500, detail="Agent registry not initialized")
|
|
626
|
+
agent_registry = cast(AgentRegistry, agent_registry)
|
|
627
|
+
|
|
628
|
+
try:
|
|
629
|
+
agent_obj = agent_registry.get_agent(agent_id)
|
|
630
|
+
except Exception as e:
|
|
631
|
+
logger.exception("Error retrieving agent")
|
|
632
|
+
raise HTTPException(status_code=500, detail=f"Error retrieving agent: {e}")
|
|
633
|
+
|
|
634
|
+
if not agent_obj:
|
|
635
|
+
raise HTTPException(status_code=404, detail="Agent not found")
|
|
636
|
+
|
|
637
|
+
agent_serialized = agent_obj.dict() if hasattr(agent_obj, "dict") else agent_obj
|
|
638
|
+
|
|
639
|
+
return CRUDResponse(
|
|
640
|
+
status=200,
|
|
641
|
+
message="Agent retrieved successfully",
|
|
642
|
+
result=cast(Dict[str, Any], agent_serialized),
|
|
643
|
+
)
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
@app.patch(
|
|
647
|
+
"/v1/agents/{agent_id}",
|
|
648
|
+
response_model=CRUDResponse,
|
|
649
|
+
summary="Update an agent",
|
|
650
|
+
description="Update an existing agent with new details. Only provided fields will be updated.",
|
|
651
|
+
tags=["Agents"],
|
|
652
|
+
openapi_extra={
|
|
653
|
+
"requestBody": {
|
|
654
|
+
"content": {
|
|
655
|
+
"application/json": {
|
|
656
|
+
"examples": {
|
|
657
|
+
"example": {
|
|
658
|
+
"summary": "Update Agent Example",
|
|
659
|
+
"value": {
|
|
660
|
+
"name": "Updated Agent Name",
|
|
661
|
+
"description": "Updated description",
|
|
662
|
+
},
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
},
|
|
668
|
+
"responses": {
|
|
669
|
+
"200": {
|
|
670
|
+
"description": "Agent updated successfully",
|
|
671
|
+
"content": {
|
|
672
|
+
"application/json": {
|
|
673
|
+
"example": {
|
|
674
|
+
"status": 200,
|
|
675
|
+
"message": "Agent updated successfully",
|
|
676
|
+
"result": {
|
|
677
|
+
"id": "agent123",
|
|
678
|
+
"name": "Updated Agent Name",
|
|
679
|
+
"description": "Updated description",
|
|
680
|
+
},
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
},
|
|
684
|
+
}
|
|
685
|
+
},
|
|
686
|
+
},
|
|
687
|
+
)
|
|
688
|
+
async def update_agent(
|
|
689
|
+
agent_id: str = FPath(..., description="ID of the agent to update", example="agent123"),
|
|
690
|
+
agent_data: AgentUpdate = Body(...),
|
|
691
|
+
):
|
|
692
|
+
"""
|
|
693
|
+
Update an existing agent.
|
|
694
|
+
"""
|
|
695
|
+
agent_registry = getattr(app.state, "agent_registry", None)
|
|
696
|
+
if agent_registry is None:
|
|
697
|
+
raise HTTPException(status_code=500, detail="Agent registry not initialized")
|
|
698
|
+
agent_registry = cast(AgentRegistry, agent_registry)
|
|
699
|
+
|
|
700
|
+
try:
|
|
701
|
+
updated_agent = agent_registry.update_agent(
|
|
702
|
+
agent_id, cast(AgentEditFields, agent_data.model_dump(exclude_unset=True))
|
|
703
|
+
)
|
|
704
|
+
except Exception as e:
|
|
705
|
+
logger.exception("Error updating agent")
|
|
706
|
+
raise HTTPException(status_code=400, detail=f"Failed to update agent: {e}")
|
|
707
|
+
|
|
708
|
+
if not updated_agent:
|
|
709
|
+
raise HTTPException(status_code=404, detail="Agent not found")
|
|
710
|
+
|
|
711
|
+
updated_agent_serialized = (
|
|
712
|
+
updated_agent.dict() if hasattr(updated_agent, "dict") else updated_agent
|
|
713
|
+
)
|
|
714
|
+
|
|
715
|
+
return CRUDResponse(
|
|
716
|
+
status=200,
|
|
717
|
+
message="Agent updated successfully",
|
|
718
|
+
result=cast(Dict[str, Any], updated_agent_serialized),
|
|
719
|
+
)
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
@app.delete(
|
|
723
|
+
"/v1/agents/{agent_id}",
|
|
724
|
+
response_model=CRUDResponse,
|
|
725
|
+
summary="Delete an agent",
|
|
726
|
+
description="Delete an existing agent by its ID.",
|
|
727
|
+
tags=["Agents"],
|
|
728
|
+
openapi_extra={
|
|
729
|
+
"responses": {
|
|
730
|
+
"200": {
|
|
731
|
+
"description": "Agent deleted successfully",
|
|
732
|
+
"content": {
|
|
733
|
+
"application/json": {
|
|
734
|
+
"example": {
|
|
735
|
+
"status": 200,
|
|
736
|
+
"message": "Agent deleted successfully",
|
|
737
|
+
"result": {},
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
},
|
|
741
|
+
}
|
|
742
|
+
},
|
|
743
|
+
},
|
|
744
|
+
)
|
|
745
|
+
async def delete_agent(
|
|
746
|
+
agent_id: str = FPath(..., description="ID of the agent to delete", example="agent123")
|
|
747
|
+
):
|
|
748
|
+
"""
|
|
749
|
+
Delete an existing agent.
|
|
750
|
+
"""
|
|
751
|
+
agent_registry = getattr(app.state, "agent_registry", None)
|
|
752
|
+
if agent_registry is None:
|
|
753
|
+
raise HTTPException(status_code=500, detail="Agent registry not initialized")
|
|
754
|
+
agent_registry = cast(AgentRegistry, agent_registry)
|
|
755
|
+
|
|
756
|
+
try:
|
|
757
|
+
success = agent_registry.delete_agent(agent_id)
|
|
758
|
+
except Exception as e:
|
|
759
|
+
logger.exception("Error deleting agent")
|
|
760
|
+
raise HTTPException(status_code=500, detail=f"Error deleting agent: {e}")
|
|
761
|
+
|
|
762
|
+
if not success:
|
|
763
|
+
raise HTTPException(status_code=404, detail="Agent not found")
|
|
764
|
+
|
|
765
|
+
return CRUDResponse(
|
|
766
|
+
status=200,
|
|
767
|
+
message="Agent deleted successfully",
|
|
768
|
+
result={},
|
|
769
|
+
)
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
@app.get(
|
|
773
|
+
"/health",
|
|
774
|
+
summary="Health Check",
|
|
775
|
+
description="Returns the health status of the API server.",
|
|
776
|
+
tags=["Health"],
|
|
777
|
+
)
|
|
778
|
+
async def health_check():
|
|
779
|
+
"""
|
|
780
|
+
Health check endpoint.
|
|
781
|
+
|
|
782
|
+
Returns:
|
|
783
|
+
A JSON object with a "status" key indicating operational status.
|
|
784
|
+
"""
|
|
785
|
+
return HealthCheckResponse(status=200, message="ok")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.2
|
|
2
2
|
Name: local-operator
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.4
|
|
4
4
|
Summary: A Python-based agent for local command execution
|
|
5
5
|
Author-email: Damian Tran <damianvtran@gmail.com>
|
|
6
6
|
License: MIT License
|
|
@@ -209,7 +209,7 @@ local-operator --agent "My Agent"
|
|
|
209
209
|
or
|
|
210
210
|
|
|
211
211
|
```bash
|
|
212
|
-
local-operator --
|
|
212
|
+
local-operator --hosting openai --model gpt-4o exec "Make a new file called test.txt and write Hello World in it" --agent "My Agent"
|
|
213
213
|
```
|
|
214
214
|
|
|
215
215
|
## Configuration
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "local-operator"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.4"
|
|
8
8
|
description = "A Python-based agent for local command execution"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
authors = [{ name = "Damian Tran", email = "damianvtran@gmail.com" }]
|
|
@@ -1,299 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
FastAPI server implementation for Local Operator API.
|
|
3
|
-
|
|
4
|
-
Provides REST endpoints for interacting with the Local Operator agent
|
|
5
|
-
through HTTP requests instead of CLI.
|
|
6
|
-
"""
|
|
7
|
-
|
|
8
|
-
import logging
|
|
9
|
-
from contextlib import asynccontextmanager
|
|
10
|
-
from importlib.metadata import version
|
|
11
|
-
from pathlib import Path
|
|
12
|
-
from typing import List, Optional
|
|
13
|
-
|
|
14
|
-
from fastapi import FastAPI, HTTPException
|
|
15
|
-
from pydantic import BaseModel, Field
|
|
16
|
-
from tiktoken import encoding_for_model
|
|
17
|
-
|
|
18
|
-
from local_operator.agents import AgentRegistry
|
|
19
|
-
from local_operator.config import ConfigManager
|
|
20
|
-
from local_operator.credentials import CredentialManager
|
|
21
|
-
from local_operator.executor import LocalCodeExecutor
|
|
22
|
-
from local_operator.model import configure_model
|
|
23
|
-
from local_operator.operator import ConversationRole, Operator, OperatorType
|
|
24
|
-
from local_operator.prompts import create_system_prompt
|
|
25
|
-
|
|
26
|
-
logger = logging.getLogger("local_operator.server")
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
class HealthCheckResponse(BaseModel):
|
|
30
|
-
"""Response from health check endpoint.
|
|
31
|
-
|
|
32
|
-
Attributes:
|
|
33
|
-
status: HTTP status code
|
|
34
|
-
message: Health check message
|
|
35
|
-
"""
|
|
36
|
-
|
|
37
|
-
status: int
|
|
38
|
-
message: str
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
class ChatOptions(BaseModel):
|
|
42
|
-
"""Options for controlling the chat generation.
|
|
43
|
-
|
|
44
|
-
Attributes:
|
|
45
|
-
temperature: Controls randomness in responses. Higher values like 0.8 make output more
|
|
46
|
-
random, while lower values like 0.2 make it more focused and deterministic.
|
|
47
|
-
Default: 0.8
|
|
48
|
-
top_p: Controls cumulative probability of tokens to sample from. Higher values (0.95) keep
|
|
49
|
-
more options, lower values (0.1) are more selective. Default: 0.9
|
|
50
|
-
top_k: Limits tokens to sample from at each step. Lower values (10) are more selective,
|
|
51
|
-
higher values (100) allow more variety. Default: 40
|
|
52
|
-
max_tokens: Maximum tokens to generate. Model may generate fewer if response completes
|
|
53
|
-
before reaching limit. Default: 4096
|
|
54
|
-
stop: List of strings that will stop generation when encountered. Default: None
|
|
55
|
-
frequency_penalty: Reduces repetition by lowering likelihood of repeated tokens.
|
|
56
|
-
Range from -2.0 to 2.0. Default: 0.0
|
|
57
|
-
presence_penalty: Increases diversity by lowering likelihood of prompt tokens.
|
|
58
|
-
Range from -2.0 to 2.0. Default: 0.0
|
|
59
|
-
seed: Random number seed for deterministic generation. Default: None
|
|
60
|
-
"""
|
|
61
|
-
|
|
62
|
-
temperature: Optional[float] = Field(None, ge=0.0, le=1.0)
|
|
63
|
-
top_p: Optional[float] = Field(None, ge=0.0, le=1.0)
|
|
64
|
-
top_k: Optional[int] = None
|
|
65
|
-
max_tokens: Optional[int] = None
|
|
66
|
-
stop: Optional[List[str]] = None
|
|
67
|
-
frequency_penalty: Optional[float] = None
|
|
68
|
-
presence_penalty: Optional[float] = None
|
|
69
|
-
seed: Optional[int] = None
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
class ChatMessage(BaseModel):
|
|
73
|
-
"""A single message in the chat conversation.
|
|
74
|
-
|
|
75
|
-
Attributes:
|
|
76
|
-
role: The role of who sent the message - "system", "user", or "assistant"
|
|
77
|
-
content: The actual text content of the message
|
|
78
|
-
"""
|
|
79
|
-
|
|
80
|
-
role: str
|
|
81
|
-
content: str
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
class ChatRequest(BaseModel):
|
|
85
|
-
"""Request body for chat generation endpoint.
|
|
86
|
-
|
|
87
|
-
Attributes:
|
|
88
|
-
hosting: Name of the hosting service to use for generation
|
|
89
|
-
model: Name of the model to use for generation
|
|
90
|
-
prompt: The prompt to generate a response for
|
|
91
|
-
stream: Whether to stream the response token by token. Default: False
|
|
92
|
-
context: Optional list of previous messages for context
|
|
93
|
-
options: Optional generation parameters to override defaults
|
|
94
|
-
"""
|
|
95
|
-
|
|
96
|
-
hosting: str
|
|
97
|
-
model: str
|
|
98
|
-
prompt: str
|
|
99
|
-
stream: bool = False
|
|
100
|
-
context: Optional[List[ChatMessage]] = None
|
|
101
|
-
options: Optional[ChatOptions] = None
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
class ChatStats(BaseModel):
|
|
105
|
-
"""Statistics about token usage for the chat request.
|
|
106
|
-
|
|
107
|
-
Attributes:
|
|
108
|
-
total_tokens: Total number of tokens used in prompt and completion
|
|
109
|
-
prompt_tokens: Number of tokens in the prompt
|
|
110
|
-
completion_tokens: Number of tokens in the completion
|
|
111
|
-
"""
|
|
112
|
-
|
|
113
|
-
total_tokens: int
|
|
114
|
-
prompt_tokens: int
|
|
115
|
-
completion_tokens: int
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
class ChatResponse(BaseModel):
|
|
119
|
-
"""Response from chat generation endpoint.
|
|
120
|
-
|
|
121
|
-
Attributes:
|
|
122
|
-
response: The generated text response
|
|
123
|
-
context: List of all messages including the new response
|
|
124
|
-
stats: Token usage statistics
|
|
125
|
-
"""
|
|
126
|
-
|
|
127
|
-
response: str
|
|
128
|
-
context: List[ChatMessage]
|
|
129
|
-
stats: ChatStats
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
@asynccontextmanager
|
|
133
|
-
async def lifespan(app: FastAPI):
|
|
134
|
-
# Initialize on startup by setting up the credential and config managers
|
|
135
|
-
config_dir = Path.home() / ".local-operator"
|
|
136
|
-
agents_dir = config_dir / "agents"
|
|
137
|
-
app.state.credential_manager = CredentialManager(config_dir=config_dir)
|
|
138
|
-
app.state.config_manager = ConfigManager(config_dir=config_dir)
|
|
139
|
-
app.state.agent_registry = AgentRegistry(config_dir=agents_dir)
|
|
140
|
-
yield
|
|
141
|
-
# Clean up on shutdown
|
|
142
|
-
app.state.credential_manager = None
|
|
143
|
-
app.state.config_manager = None
|
|
144
|
-
app.state.agent_registry = None
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
app = FastAPI(
|
|
148
|
-
title="Local Operator API",
|
|
149
|
-
description="REST API interface for Local Operator agent",
|
|
150
|
-
version=version("local-operator"),
|
|
151
|
-
lifespan=lifespan,
|
|
152
|
-
)
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
def create_operator(request_hosting: str, request_model: str) -> Operator:
|
|
156
|
-
"""Create a LocalCodeExecutor for a single chat request using the app state managers
|
|
157
|
-
and the hosting/model provided in the request."""
|
|
158
|
-
credential_manager = getattr(app.state, "credential_manager", None)
|
|
159
|
-
config_manager = getattr(app.state, "config_manager", None)
|
|
160
|
-
agent_registry = getattr(app.state, "agent_registry", None)
|
|
161
|
-
if credential_manager is None or config_manager is None or agent_registry is None:
|
|
162
|
-
raise HTTPException(status_code=500, detail="Server configuration not initialized")
|
|
163
|
-
|
|
164
|
-
if not request_hosting:
|
|
165
|
-
raise ValueError("Hosting is not set")
|
|
166
|
-
|
|
167
|
-
model_instance = configure_model(
|
|
168
|
-
credential_manager=credential_manager,
|
|
169
|
-
hosting=request_hosting,
|
|
170
|
-
model=request_model,
|
|
171
|
-
)
|
|
172
|
-
|
|
173
|
-
if not model_instance:
|
|
174
|
-
raise ValueError("No model instance configured")
|
|
175
|
-
|
|
176
|
-
executor = LocalCodeExecutor(
|
|
177
|
-
model=model_instance,
|
|
178
|
-
max_conversation_history=100,
|
|
179
|
-
detail_conversation_length=10,
|
|
180
|
-
can_prompt_user=False,
|
|
181
|
-
)
|
|
182
|
-
|
|
183
|
-
return Operator(
|
|
184
|
-
executor=executor,
|
|
185
|
-
credential_manager=credential_manager,
|
|
186
|
-
model_instance=executor.model,
|
|
187
|
-
config_manager=config_manager,
|
|
188
|
-
type=OperatorType.SERVER,
|
|
189
|
-
agent_registry=agent_registry,
|
|
190
|
-
current_agent=None,
|
|
191
|
-
training_mode=False,
|
|
192
|
-
)
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
@app.post(
|
|
196
|
-
"/chat",
|
|
197
|
-
response_model=ChatResponse,
|
|
198
|
-
summary="Process chat request",
|
|
199
|
-
description="Accepts a prompt and optional context/configuration, returns the model response "
|
|
200
|
-
"and conversation history.",
|
|
201
|
-
openapi_extra={
|
|
202
|
-
"requestBody": {
|
|
203
|
-
"content": {
|
|
204
|
-
"application/json": {
|
|
205
|
-
"examples": {
|
|
206
|
-
"example": {
|
|
207
|
-
"summary": "Example Request",
|
|
208
|
-
"value": {
|
|
209
|
-
"prompt": "Print 'Hello, world!'",
|
|
210
|
-
"hosting": "openai",
|
|
211
|
-
"model": "gpt-4o",
|
|
212
|
-
"context": [],
|
|
213
|
-
"options": {"temperature": 0.7, "top_p": 0.9},
|
|
214
|
-
},
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
},
|
|
221
|
-
)
|
|
222
|
-
async def chat_endpoint(request: ChatRequest):
|
|
223
|
-
"""
|
|
224
|
-
Process a chat request and return the response with context.
|
|
225
|
-
|
|
226
|
-
The endpoint accepts a JSON payload containing the prompt, hosting, model selection, and
|
|
227
|
-
optional parameters.
|
|
228
|
-
---
|
|
229
|
-
responses:
|
|
230
|
-
200:
|
|
231
|
-
description: Successful response containing the model output and conversation history.
|
|
232
|
-
500:
|
|
233
|
-
description: Internal Server Error
|
|
234
|
-
"""
|
|
235
|
-
try:
|
|
236
|
-
# Create a new executor for this request using the provided hosting and model
|
|
237
|
-
operator = create_operator(request.hosting, request.model)
|
|
238
|
-
|
|
239
|
-
if request.context and len(request.context) > 0:
|
|
240
|
-
# Override the default system prompt with the provided context
|
|
241
|
-
conversation_history = [
|
|
242
|
-
{"role": msg.role, "content": msg.content} for msg in request.context
|
|
243
|
-
]
|
|
244
|
-
operator.executor.initialize_conversation_history(conversation_history)
|
|
245
|
-
else:
|
|
246
|
-
operator.executor.initialize_conversation_history()
|
|
247
|
-
|
|
248
|
-
# Configure model options if provided
|
|
249
|
-
if request.options:
|
|
250
|
-
temperature = request.options.temperature or operator.executor.model.temperature
|
|
251
|
-
if temperature is not None:
|
|
252
|
-
operator.model.temperature = temperature
|
|
253
|
-
operator.model.top_p = request.options.top_p or operator.executor.model.top_p
|
|
254
|
-
|
|
255
|
-
response_json = await operator.handle_user_input(request.prompt)
|
|
256
|
-
if response_json is not None:
|
|
257
|
-
response_content = response_json.response
|
|
258
|
-
else:
|
|
259
|
-
response_content = ""
|
|
260
|
-
|
|
261
|
-
# Calculate token stats using tiktoken
|
|
262
|
-
tokenizer = encoding_for_model(request.model)
|
|
263
|
-
prompt_tokens = sum(
|
|
264
|
-
len(tokenizer.encode(msg["content"])) for msg in operator.executor.conversation_history
|
|
265
|
-
)
|
|
266
|
-
completion_tokens = len(tokenizer.encode(response_content))
|
|
267
|
-
total_tokens = prompt_tokens + completion_tokens
|
|
268
|
-
|
|
269
|
-
return ChatResponse(
|
|
270
|
-
response=response_content,
|
|
271
|
-
context=[
|
|
272
|
-
ChatMessage(role=msg["role"], content=msg["content"])
|
|
273
|
-
for msg in operator.executor.conversation_history
|
|
274
|
-
],
|
|
275
|
-
stats=ChatStats(
|
|
276
|
-
total_tokens=total_tokens,
|
|
277
|
-
prompt_tokens=prompt_tokens,
|
|
278
|
-
completion_tokens=completion_tokens,
|
|
279
|
-
),
|
|
280
|
-
)
|
|
281
|
-
|
|
282
|
-
except Exception:
|
|
283
|
-
logger.exception("Unexpected error while processing chat request")
|
|
284
|
-
raise HTTPException(status_code=500, detail="Internal Server Error")
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
@app.get(
|
|
288
|
-
"/health",
|
|
289
|
-
summary="Health Check",
|
|
290
|
-
description="Returns the health status of the API server.",
|
|
291
|
-
)
|
|
292
|
-
async def health_check():
|
|
293
|
-
"""
|
|
294
|
-
Health check endpoint.
|
|
295
|
-
|
|
296
|
-
Returns:
|
|
297
|
-
A JSON object with a "status" key indicating operational status.
|
|
298
|
-
"""
|
|
299
|
-
return HealthCheckResponse(status=200, message="ok")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|