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.
Files changed (27) hide show
  1. {local_operator-0.2.2 → local_operator-0.2.4}/PKG-INFO +2 -2
  2. {local_operator-0.2.2 → local_operator-0.2.4}/README.md +1 -1
  3. {local_operator-0.2.2 → local_operator-0.2.4}/local_operator/agents.py +1 -1
  4. {local_operator-0.2.2 → local_operator-0.2.4}/local_operator/cli.py +4 -11
  5. {local_operator-0.2.2 → local_operator-0.2.4}/local_operator/operator.py +23 -0
  6. local_operator-0.2.4/local_operator/server.py +785 -0
  7. {local_operator-0.2.2 → local_operator-0.2.4}/local_operator.egg-info/PKG-INFO +2 -2
  8. {local_operator-0.2.2 → local_operator-0.2.4}/pyproject.toml +1 -1
  9. local_operator-0.2.2/local_operator/server.py +0 -299
  10. {local_operator-0.2.2 → local_operator-0.2.4}/LICENSE +0 -0
  11. {local_operator-0.2.2 → local_operator-0.2.4}/local_operator/__init__.py +0 -0
  12. {local_operator-0.2.2 → local_operator-0.2.4}/local_operator/config.py +0 -0
  13. {local_operator-0.2.2 → local_operator-0.2.4}/local_operator/console.py +0 -0
  14. {local_operator-0.2.2 → local_operator-0.2.4}/local_operator/credentials.py +0 -0
  15. {local_operator-0.2.2 → local_operator-0.2.4}/local_operator/executor.py +0 -0
  16. {local_operator-0.2.2 → local_operator-0.2.4}/local_operator/mocks.py +0 -0
  17. {local_operator-0.2.2 → local_operator-0.2.4}/local_operator/model.py +0 -0
  18. {local_operator-0.2.2 → local_operator-0.2.4}/local_operator/prompts.py +0 -0
  19. {local_operator-0.2.2 → local_operator-0.2.4}/local_operator/tools.py +0 -0
  20. {local_operator-0.2.2 → local_operator-0.2.4}/local_operator/types.py +0 -0
  21. {local_operator-0.2.2 → local_operator-0.2.4}/local_operator.egg-info/SOURCES.txt +0 -0
  22. {local_operator-0.2.2 → local_operator-0.2.4}/local_operator.egg-info/dependency_links.txt +0 -0
  23. {local_operator-0.2.2 → local_operator-0.2.4}/local_operator.egg-info/entry_points.txt +0 -0
  24. {local_operator-0.2.2 → local_operator-0.2.4}/local_operator.egg-info/requires.txt +0 -0
  25. {local_operator-0.2.2 → local_operator-0.2.4}/local_operator.egg-info/top_level.txt +0 -0
  26. {local_operator-0.2.2 → local_operator-0.2.4}/setup.cfg +0 -0
  27. {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.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 --agent "My Agent" exec "Make a new file called test.txt and write Hello World in it"
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 --agent "My Agent" exec "Make a new file called test.txt and write Hello World in it"
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 edit_agent(self, agent_id: str, updated_metadata: AgentEditFields) -> None:
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
- system_prompt = create_system_prompt()
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.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 --agent "My Agent" exec "Make a new file called test.txt and write Hello World in it"
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.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