Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 68 additions & 15 deletions .config.example.json
Original file line number Diff line number Diff line change
@@ -1,22 +1,72 @@
{
"agent": {
"streaming": false,
"llm": {
"provider": "nvidia",
"model": "nemotron-nano-12b-instruct-v1:1"
"agents": {
"defaults": {
"streaming": false,
"maxToolIterations": 40
},
"stt": {
"enabled": true,
"provider": "sarvam",
"model": "saaras:v3"
"list": [
{
"id": "operator",
"description": "Default operator agent",
"policy": "operator",
"llmConfig": {
"provider": "nvidia",
"model": "nemotron-nano-12b-instruct-v1:1"
},
"browserUse": true,
"computerUse": false
},
{
"id": "web_agent",
"description": "Web-focused helper agent",
"policy": "web_agent",
"llmConfig": {
"provider": "nvidia",
"model": "nemotron-nano-12b-instruct-v1:1"
},
"browserUse": true,
"computerUse": false
},
{
"id": "code_agent",
"description": "Code and terminal helper agent",
"policy": "code_agent",
"llmConfig": {
"provider": "nvidia",
"model": "nemotron-nano-12b-instruct-v1:1"
},
"browserUse": false,
"computerUse": false
}
]
},
"stt": {
"enabled": true,
"provider": "sarvam",
"model": "saaras:v3"
},
"tts": {
"enabled": true,
"provider": "sarvam",
"model": "bulbul:v3",
"voice": "kavya"
},
"policies": {
"operator": {
"allowedTools": ["agents.*", "message.*", "channel.send"]
},
"tts": {
"enabled": true,
"provider": "sarvam",
"model": "bulbul:v3",
"voice": "kavya"
"web_agent": {
"allowedTools": ["web.*", "filesystem.read", "filesystem.list", "message.*"]
},
"code_agent": {
"allowedTools": ["filesystem.*", "terminal.exec", "process.*", "message.*"]
}
},
"governance": {
"protectCodebase": true,
"protectRuntimeConfig": true,
"protectedPaths": []
},
"providers": {
"nvidia": {
"api_key": "API_KEY"
Expand All @@ -26,6 +76,9 @@
},
"sarvam": {
"api_key": "API_KEY"
},
"deepseek": {
"apiKey": "API_KEY"
}
}
}
}
78 changes: 53 additions & 25 deletions operator_use/agent/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from operator_use.messages import AIMessage, HumanMessage, ImageMessage, ToolMessage
from operator_use.agent.context import Context
from operator_use.agent.tools import ToolRegistry, BUILTIN_TOOLS
from operator_use.agent.tools.governance import GovernanceProfile
from operator_use.bus import IncomingMessage
from operator_use.providers.events import LLMEventType, LLMStreamEventType
from operator_use.session import SessionStore, Session
Expand Down Expand Up @@ -61,6 +62,8 @@ def __init__(
exclude_tools: list | None = None,
acp_registry: dict | None = None,
plugins: "list[Plugin] | None" = None,
governance_profile: GovernanceProfile | None = None,
protected_paths: list[Path] | None = None,
):
self.agent_id = agent_id
self.description = description
Expand All @@ -76,9 +79,16 @@ def __init__(
self.cron = cron
self.gateway = gateway
self.bus = bus
self.subagent_store = SubagentManager(llm=llm, bus=bus)
self.protected_paths = [Path(path).expanduser().resolve() for path in (protected_paths or [])]
self.subagent_store = SubagentManager(
llm=llm,
bus=bus,
workspace=self.workspace,
protected_paths=self.protected_paths,
)
self.process_store = ProcessStore()
self.hooks = Hooks()
self.governance_profile = governance_profile

self.tool_register.register_tools(BUILTIN_TOOLS)
if exclude_tools:
Expand All @@ -95,6 +105,10 @@ def __init__(
self.tool_register.set_extension("_llm", self.llm)
self.tool_register.set_extension("_agent", self)
self.tool_register.set_extension("_agent_id", self.agent_id)
if self.protected_paths:
self.tool_register.set_extension("_protected_paths", self.protected_paths)
if self.governance_profile is not None:
self.tool_register.set_extension("_governance_profile", self.governance_profile)

# Wire plugins
self.plugins: "list[Plugin]" = plugins or []
Expand Down Expand Up @@ -160,45 +174,59 @@ async def run(
pending_replies: Shared dict for tools that wait for a user reply.
"""
# Set per-message tool extensions
delegated_profile = None
if incoming:
self.tool_register.set_extension("_channel", incoming.channel)
self.tool_register.set_extension("_chat_id", incoming.chat_id)
self.tool_register.set_extension("_account_id", incoming.account_id)
self.tool_register.set_extension("_metadata", incoming.metadata or {})
self.tool_register.set_extension("_session_id", session_id)
delegated_profile = GovernanceProfile.from_dict(
(incoming.metadata or {}).get("_governance_profile")
)
if delegated_profile is not None:
delegated_profile = delegated_profile.with_parent(self.governance_profile)
self.tool_register.set_extension("_governance_profile", delegated_profile)
if pending_replies is not None:
self.tool_register.set_extension("_pending_replies", pending_replies)

session = self.sessions.get_or_create(session_id=session_id)
session.add_message(message)

await self.hooks.emit(
HookEvent.BEFORE_AGENT_START,
BeforeAgentStartContext(message=incoming, session=session),
)
try:
session = self.sessions.get_or_create(session_id=session_id)
session.add_message(message)

if publish_stream is not None:
response_message = await self._loop_stream(
session=session, publish_stream=publish_stream, message=incoming
await self.hooks.emit(
HookEvent.BEFORE_AGENT_START,
BeforeAgentStartContext(message=incoming, session=session),
)
else:
response_message = await self._loop(session=session, message=incoming)

# Allow hooks to modify the final response before saving
end_ctx = await self.hooks.emit(
HookEvent.BEFORE_AGENT_END,
BeforeAgentEndContext(message=incoming, session=session, response=response_message),
)
response_message = end_ctx.response
if publish_stream is not None:
response_message = await self._loop_stream(
session=session, publish_stream=publish_stream, message=incoming
)
else:
response_message = await self._loop(session=session, message=incoming)

self.sessions.save(session)
# Allow hooks to modify the final response before saving
end_ctx = await self.hooks.emit(
HookEvent.BEFORE_AGENT_END,
BeforeAgentEndContext(message=incoming, session=session, response=response_message),
)
response_message = end_ctx.response

await self.hooks.emit(
HookEvent.AFTER_AGENT_END,
AfterAgentEndContext(message=incoming, session=session, response=response_message),
)
self.sessions.save(session)

await self.hooks.emit(
HookEvent.AFTER_AGENT_END,
AfterAgentEndContext(message=incoming, session=session, response=response_message),
)

return response_message
return response_message
finally:
if delegated_profile is not None:
if self.governance_profile is not None:
self.tool_register.set_extension("_governance_profile", self.governance_profile)
else:
self.tool_register.unset_extension("_governance_profile")

# ------------------------------------------------------------------
# Reaction handling (no LLM call — update metadata only)
Expand Down
72 changes: 48 additions & 24 deletions operator_use/agent/tools/builtin/filesystem.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,39 @@
import json
from operator_use.utils.helper import resolve,is_binary_file,ensure_directory
from operator_use.tools.service import Tool,ToolResult,MAX_TOOL_OUTPUT_LENGTH
from operator_use.paths import get_named_workspace_dir
from pydantic import BaseModel, Field, field_validator
import json
from pathlib import Path

from pydantic import BaseModel, Field, field_validator

from operator_use.agent.tools.path_guard import (
PathAccessError,
ensure_allowed_directory,
ensure_allowed_path,
get_workspace_root,
)
from operator_use.tools.service import Tool,ToolResult,MAX_TOOL_OUTPUT_LENGTH
from operator_use.utils.helper import is_binary_file,ensure_directory


def _get_workspace(**kwargs) -> Path:
return kwargs.get("_workspace") or get_named_workspace_dir("operator")
return get_workspace_root(**kwargs)


def _get_protected_paths(**kwargs) -> list[Path] | None:
return kwargs.get("_protected_paths")


class ReadFile(BaseModel):
path: str = Field(...,description="Absolute path or path relative to the codebase root. Use list_dir first if you're unsure where the file is.")
path: str = Field(...,description="Absolute path or path relative to the workspace root. Use list_dir first if you're unsure where the file is.")
start_line: int | None = Field(default=None,description="1-based line to start reading from (inclusive). Use with end_line to read a specific section of a large file.",examples=[1])
end_line: int | None = Field(default=None,description="1-based line to stop reading at (inclusive). Use with start_line to avoid reading the whole file when you only need a section.",examples=[10])

@Tool(name="read_file",description="Read a text file and return its contents with line numbers (format: N | content). Use start_line/end_line to read a slice of a large file. Cannot read binary files — use terminal for those.",model=ReadFile)
async def read_file(path: str, start_line: int | None = None, end_line: int | None = None, **kwargs) -> ToolResult:
workspace = _get_workspace(**kwargs)
resolved_path=resolve(base=workspace,path=path)
protected_paths = _get_protected_paths(**kwargs)
try:
resolved_path = ensure_allowed_path(path, workspace=workspace, protected_paths=protected_paths)
except PathAccessError as e:
return ToolResult.error_result(str(e))
if not resolved_path.exists():
return ToolResult.error_result(f"File not found: {resolved_path}")

Expand All @@ -42,23 +59,29 @@ async def read_file(path: str, start_line: int | None = None, end_line: int | No
return ToolResult.success_result(content)

class WriteFile(BaseModel):
path: str = Field(...,description="Absolute path or path relative to the codebase root. Parent directories are created automatically.")
path: str = Field(...,description="Absolute path or path relative to the workspace root. Parent directories are created automatically.")
content: str = Field(...,description="Full content to write. This replaces the entire file — use edit_file to change only part of an existing file.")
overwrite: bool = Field(default=True,description="Set to False to prevent accidentally overwriting an existing file. Raises an error if the file already exists.")
empty: bool = Field(default=False,description="Set to True only when intentionally writing an empty file (e.g. a placeholder). Prevents accidental empty writes by default.")

@Tool(name="write_file",description="Create a new file or fully overwrite an existing one. Use for new files or complete rewrites. To change only part of a file, use edit_file instead. Parent directories are created automatically.",model=WriteFile)
async def write_file(path: str, content: str, overwrite: bool = True, empty: bool = False, **kwargs) -> ToolResult:
workspace = _get_workspace(**kwargs)
resolved_path=resolve(base=workspace,path=path)
protected_paths = _get_protected_paths(**kwargs)
try:
resolved_path = ensure_allowed_path(path, workspace=workspace, protected_paths=protected_paths)
except PathAccessError as e:
return ToolResult.error_result(str(e))
file_exists=resolved_path.exists()
if file_exists and not overwrite:
return ToolResult.error_result(f"File exists and overwrite=False: {resolved_path}")
if not content.strip() and not empty:
return ToolResult.error_result("Content is empty. Set empty=True to allow writing empty files.")
try:
ensure_directory(resolved_path.parent)
resolved_path.write_text(content,encoding='utf-8')
tmp_path = resolved_path.with_suffix(resolved_path.suffix + ".tmp")
tmp_path.write_text(content,encoding='utf-8')
tmp_path.replace(resolved_path)
except (OSError,IOError) as e:
return ToolResult.error_result(f"Failed to write file: {resolved_path}. {e}")
return ToolResult.success_result(f"{'Overwrote' if file_exists else 'Created'} file: {resolved_path}")
Expand All @@ -69,7 +92,7 @@ class Edit(BaseModel):
new_content: str = Field(...,description="The replacement text. Set to empty string to delete the chunk.")

class EditFile(BaseModel):
path: str = Field(...,description="Absolute path or path relative to the codebase root.")
path: str = Field(...,description="Absolute path or path relative to the workspace root.")
edits: list[Edit] = Field(...,description="One or more edits to apply in order. Each entry finds old_content and replaces it with new_content. Applied sequentially — one read, one write.")

@field_validator("edits", mode="before")
Expand All @@ -84,14 +107,17 @@ def coerce_edits(cls, v):

@Tool(name="edit_file",description="Edit a file by replacing exact chunks of text. Pass one or more {old_content, new_content} pairs — applied in order on a single read/write. Set new_content to empty string to delete a chunk. Always read the file first to get the exact text. Use write_file for full rewrites.",model=EditFile)
async def edit_file(path: str, edits: list[dict], **kwargs) -> ToolResult:
# Coerce edits if the LLM passed a JSON-encoded string instead of a list
if isinstance(edits, str):
try:
edits = json.loads(edits)
except json.JSONDecodeError as e:
return ToolResult.error_result(f"edits must be a list, got invalid JSON string: {e}")
workspace = _get_workspace(**kwargs)
resolved_path=resolve(base=workspace,path=path)
protected_paths = _get_protected_paths(**kwargs)
try:
resolved_path = ensure_allowed_path(path, workspace=workspace, protected_paths=protected_paths)
except PathAccessError as e:
return ToolResult.error_result(str(e))
if not resolved_path.exists():
return ToolResult.error_result(f"File not found: {resolved_path}")
if not resolved_path.is_file():
Expand All @@ -116,7 +142,9 @@ async def edit_file(path: str, edits: list[dict], **kwargs) -> ToolResult:
content = content.replace(old, new, 1)

try:
resolved_path.write_text(content,encoding='utf-8')
tmp_path = resolved_path.with_suffix(resolved_path.suffix + ".tmp")
tmp_path.write_text(content,encoding='utf-8')
tmp_path.replace(resolved_path)
except (OSError, IOError) as e:
return ToolResult.error_result(f"Failed to write file: {resolved_path}. {e}")

Expand All @@ -129,13 +157,11 @@ class ListDir(BaseModel):
@Tool(name="list_dir",description="List files and subdirectories inside a directory. Directories are shown first, then files, both sorted alphabetically. Use this to explore the filesystem before reading or editing files.",model=ListDir)
async def list_dir(path: str = '.', **kwargs) -> ToolResult:
workspace = _get_workspace(**kwargs)
resolved_path=resolve(base=workspace,path=path)

if not resolved_path.exists():
return ToolResult.error_result(f"Directory not found: {resolved_path}")

if not resolved_path.is_dir():
return ToolResult.error_result(f"Path is not a directory: {resolved_path}")
protected_paths = _get_protected_paths(**kwargs)
try:
resolved_path = ensure_allowed_directory(path, workspace=workspace, protected_paths=protected_paths)
except PathAccessError as e:
return ToolResult.error_result(str(e))

try:
items=sorted(resolved_path.iterdir(),key=lambda x: (not x.is_dir(),x.name.lower()))
Expand All @@ -155,5 +181,3 @@ async def list_dir(path: str = '.', **kwargs) -> ToolResult:
output="\n".join(lines)

return ToolResult.success_result(output)


Loading