Skip to content

Commit 905a4fa

Browse files
glp-92Copilot
andcommitted
Enhance agent functionality and improve error handling; add agent-run script and validate repository paths
Co-authored-by: Copilot <copilot@github.com>
1 parent 7479057 commit 905a4fa

11 files changed

Lines changed: 268 additions & 31 deletions

File tree

README.md

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -62,22 +62,34 @@ uv run pre-commit run --all-files
6262
docker compose up -d
6363
```
6464

65-
## Ejecutar API en local (modo desarrollo)
66-
67-
No aplica: la API intermedia fue eliminada para simplificar el stack.
68-
6965
## Ejecutar agente por CLI
7066

7167
El agente recibe el prompt como argumento:
7268

7369
```bash
74-
uv run python agent/src/main.py "tu prompt aqui"
70+
./agent-run "tu prompt aqui"
71+
```
72+
73+
### Uso desde cualquier repositorio objetivo
74+
75+
Si ya estás parado en el repositorio donde quieres que el agente trabaje:
76+
77+
```bash
78+
REPOSITORY_ROOT_PATH="$PWD" /home/glp-desktop/Workspace/Open-Coder-Agent/agent-run "tu prompt aqui"
79+
```
80+
81+
Eso ejecuta el agente instalado en Open-Coder-Agent, pero operando sobre el repo actual (`$PWD`).
82+
83+
Comando avanzado equivalente (solo si lo necesitas):
84+
85+
```bash
86+
REPOSITORY_ROOT_PATH="$PWD" uv --directory /home/glp-desktop/Workspace/Open-Coder-Agent run python agent/src/main.py "tu prompt aqui"
7587
```
7688

7789
Ejemplo:
7890

7991
```bash
80-
uv run python agent/src/main.py "revisa el modulo de tools y propone mejoras de rendimiento"
92+
./agent-run "revisa el modulo de tools y propone mejoras de rendimiento"
8193
```
8294

8395
## Verificación rápida de calidad

agent-run

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
if [[ $# -lt 1 ]]; then
5+
echo 'Usage: agent-run "your prompt"' >&2
6+
exit 2
7+
fi
8+
9+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
10+
TARGET_ROOT="${REPOSITORY_ROOT_PATH:-$PWD}"
11+
PROMPT="$*"
12+
13+
REPOSITORY_ROOT_PATH="$TARGET_ROOT" uv --directory "$SCRIPT_DIR" run python agent/src/main.py "$PROMPT"

agent/src/config/config.py

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from pathlib import Path
33

44
from dotenv import load_dotenv
5-
from pydantic import BaseModel, Field, model_validator
5+
from pydantic import BaseModel, Field, field_validator, model_validator
66

77
load_dotenv(dotenv_path=Path(__file__).resolve().parent.parent.parent.parent / ".env")
88

@@ -26,6 +26,16 @@ class Config(BaseModel):
2626
chat_window_size: int = Field(default=6, ge=4)
2727
repository_root_path: str = Field(min_length=1)
2828

29+
@field_validator("repository_root_path")
30+
@classmethod
31+
def _validate_repository_root_path(cls, value: str) -> str:
32+
root = Path(value).expanduser().resolve()
33+
if not root.is_absolute():
34+
raise ValueError("repository_root_path must be an absolute path")
35+
if not root.exists() or not root.is_dir():
36+
raise ValueError("repository_root_path must exist and be a directory")
37+
return str(root)
38+
2939
@model_validator(mode="after")
3040
def _validate_message_windows(self):
3141
if self.max_messages_for_summary < self.messages_to_summarize:
@@ -41,11 +51,11 @@ def _validate_message_windows(self):
4151
config = Config(
4252
ollama_url=_first_env("OLLAMA_URL", default="http://localhost:11434"),
4353
llm_model=_first_env("MODEL_NAME", default="qwen3.5:2b"),
44-
model_num_ctx=int(_first_env("AGENT_MODEL_NUM_CTX", "MODEL_NUM_CTX", default="4096")),
54+
model_num_ctx=_first_env("AGENT_MODEL_NUM_CTX", "MODEL_NUM_CTX", default="4096"),
4555
agent_config_prompt=prompt,
46-
max_steps=int(_first_env("AGENT_MAX_STEPS", "MAX_STEPS", default="20")),
47-
max_messages_for_summary=int(_first_env("AGENT_MAX_MESSAGES_FOR_SUMMARY", default="10")),
48-
messages_to_summarize=int(_first_env("AGENT_MESSAGES_TO_SUMMARIZE", default="4")),
49-
chat_window_size=int(_first_env("AGENT_CHAT_WINDOW_SIZE", default="6")),
56+
max_steps=_first_env("AGENT_MAX_STEPS", "MAX_STEPS", default="20"),
57+
max_messages_for_summary=_first_env("AGENT_MAX_MESSAGES_FOR_SUMMARY", default="10"),
58+
messages_to_summarize=_first_env("AGENT_MESSAGES_TO_SUMMARIZE", default="4"),
59+
chat_window_size=_first_env("AGENT_CHAT_WINDOW_SIZE", default="6"),
5060
repository_root_path=_first_env("REPOSITORY_ROOT_PATH") or "",
5161
)

agent/src/config/prompt.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,22 @@ Careful local coding agent optimized for small offline models.
1616
- Avoid `get_repository_tree` unless the local folder structure is unclear.
1717
- Use `create_file` only when the file does not exist.
1818
- Use `write_file` only after reading enough of the target file to preserve it safely.
19-
- After Python edits, run `run_linting` on the touched paths.
19+
- For create/init/scaffold requests, you must call at least one writing tool (`make_dirs`, `create_file`, or `write_file`) before finishing.
20+
- Never end a create/init/scaffold request with only linting or inspection calls.
21+
- Run `run_linting` only after Python edits and only with explicit touched paths.
22+
- If a tool call fails due invalid arguments/schema, immediately retry the same tool with corrected arguments.
23+
- Do not finish the task right after a tool-argument error.
24+
- If `list_dir` returns an empty directory during a scaffold task, do not call `list_dir` on the same path again; create the next required files.
2025

2126
# Workflow
2227

2328
1. Inspect narrowly.
2429
2. Make the smallest safe change.
2530
3. Validate the touched Python files.
2631
4. Return a short summary with remaining risk, if any.
32+
33+
# Completion criteria
34+
35+
- Do not finish if the task asks to create/modify code and no successful writing tool has run.
36+
- Do not finish immediately after a tool error; retry with corrected arguments first.
37+
- For scaffold tasks, ensure at least one runnable entry file and one relevant test file are created before finishing.

agent/src/graph/nodes.py

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,62 @@
11
from config.config import config
22
from graph.state import AgentState
3-
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
3+
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage
44
from langgraph.prebuilt import ToolNode
55
from model.model import model, summary_model
66
from tools.registry import TOOLS_REGISTRY
77

88
tool_node = ToolNode(tools=TOOLS_REGISTRY, messages_key="messages")
99

1010

11+
def _is_repeating_tool_result(messages: list) -> bool:
12+
recent_non_system = [message for message in messages if message.type != "system"][-8:]
13+
recent_tool_messages = [message for message in recent_non_system if isinstance(message, ToolMessage)]
14+
if len(recent_tool_messages) < 2:
15+
return False
16+
last_tool = recent_tool_messages[-1]
17+
prev_tool = recent_tool_messages[-2]
18+
return last_tool.name == prev_tool.name and str(last_tool.content).strip() == str(prev_tool.content).strip()
19+
20+
21+
def _task_requires_file_changes(messages: list) -> bool:
22+
first_human = next((message for message in messages if isinstance(message, HumanMessage)), None)
23+
if not first_human:
24+
return False
25+
prompt_text = str(first_human.content).lower()
26+
write_keywords = [
27+
"crea",
28+
"create",
29+
"implement",
30+
"estructura",
31+
"scaffold",
32+
"refactor",
33+
"fix",
34+
"corrige",
35+
"modifica",
36+
"build",
37+
]
38+
return any(keyword in prompt_text for keyword in write_keywords)
39+
40+
41+
def _has_successful_write(messages: list) -> bool:
42+
write_tools = {"make_dir", "make_dirs", "create_file", "write_file"}
43+
for message in messages:
44+
if isinstance(message, ToolMessage) and message.name in write_tools:
45+
content = str(message.content)
46+
if "Success:" in content:
47+
return True
48+
return False
49+
50+
51+
def _last_tool_error(messages: list) -> bool:
52+
for message in reversed(messages):
53+
if isinstance(message, ToolMessage):
54+
return str(message.content).strip().startswith("Error")
55+
if isinstance(message, AIMessage):
56+
break
57+
return False
58+
59+
1160
def memory_manager_node(state: AgentState):
1261
"""
1362
Makes a summary of oldest 'config.messages_to_summarize' messages
@@ -38,6 +87,18 @@ def explorer_node(state: AgentState):
3887
if steps > config.max_steps:
3988
return {"steps": steps, "messages": [AIMessage(content="Stopping: too many steps")]}
4089
all_messages = state["messages"]
90+
if _is_repeating_tool_result(all_messages):
91+
return {
92+
"steps": steps,
93+
"messages": [
94+
AIMessage(
95+
content=(
96+
"Stopping to avoid a tool loop: same tool result was repeated. "
97+
"Use a different tool or finalize with a concise result."
98+
)
99+
)
100+
],
101+
}
41102
system_message = all_messages[0]
42103
window_size = config.chat_window_size
43104
if len(all_messages) > window_size:
@@ -46,6 +107,18 @@ def explorer_node(state: AgentState):
46107
messages_to_send = [system_message, *chat_context]
47108
else:
48109
messages_to_send = all_messages
110+
111+
if _last_tool_error(all_messages):
112+
messages_to_send = [
113+
*messages_to_send,
114+
HumanMessage(content="The previous tool call failed. Retry with corrected tool arguments."),
115+
]
116+
elif _task_requires_file_changes(all_messages) and not _has_successful_write(all_messages):
117+
messages_to_send = [
118+
*messages_to_send,
119+
HumanMessage(content="This task requires file changes. Use writing tools before finishing."),
120+
]
121+
49122
response = model.invoke(messages_to_send)
50123
return {"messages": [response], "steps": steps}
51124

@@ -54,6 +127,12 @@ def router_logic(state: AgentState):
54127
"""
55128
Control function that decides next step
56129
"""
130+
if state.get("steps", 0) < config.max_steps:
131+
if _last_tool_error(state["messages"]):
132+
return "retry"
133+
if _task_requires_file_changes(state["messages"]) and not _has_successful_write(state["messages"]):
134+
return "retry"
135+
57136
last_message = state["messages"][-1]
58137
if getattr(last_message, "tool_calls", None):
59138
return "tool_executor"

agent/src/graph/workflow.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@
77
workflow.add_node("tool_executor", tool_node)
88
workflow.add_node("memory_manager", memory_manager_node)
99
workflow.set_entry_point("explorer")
10-
workflow.add_conditional_edges("explorer", router_logic, {"tool_executor": "tool_executor", "end": END})
10+
workflow.add_conditional_edges(
11+
"explorer", router_logic, {"tool_executor": "tool_executor", "retry": "explorer", "end": END}
12+
)
1113
workflow.add_edge("tool_executor", "memory_manager")
1214
workflow.add_edge("memory_manager", "explorer")
1315
graph = workflow.compile()

agent/src/main.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,5 +20,11 @@ def run(user_input: str) -> None:
2020

2121

2222
if __name__ == "__main__":
23-
user_input: str = sys.argv[1]
23+
if len(sys.argv) < 2:
24+
print('Usage: python agent/src/main.py "your prompt"') # noqa: T201
25+
raise SystemExit(2)
26+
user_input: str = " ".join(sys.argv[1:]).strip()
27+
if not user_input:
28+
print("Error: prompt cannot be empty") # noqa: T201
29+
raise SystemExit(2)
2430
run(user_input=user_input)

agent/src/tools/bash/executor.py

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
MAX_BASH_TIMEOUT_SECONDS = 20
99
MAX_BASH_OUTPUT_CHARS = 8000
10-
FORBIDDEN_SHELL_TOKENS = {"&&", "||", ";", "|", ">", "<", "$(", "`"}
10+
UNSUPPORTED_SHELL_OPERATORS = {"&&", "||", ";", "|", ">", "<"}
1111
READ_ONLY_COMMAND_ALLOWLIST = {
1212
"pwd",
1313
"ls",
@@ -20,8 +20,8 @@
2020
"stat",
2121
"du",
2222
"git",
23-
"python",
2423
}
24+
READ_ONLY_GIT_SUBCOMMANDS = {"status", "diff", "log", "show", "rev-parse", "branch", "ls-files"}
2525

2626

2727
def _truncate_output(output: str, max_chars: int) -> str:
@@ -30,11 +30,27 @@ def _truncate_output(output: str, max_chars: int) -> str:
3030
return f"{output[:max_chars]}\n... output truncated at {max_chars} chars"
3131

3232

33+
def _has_unsafe_path_args(parsed: list[str]) -> bool:
34+
for arg in parsed[1:]:
35+
if not arg or arg.startswith("-"):
36+
continue
37+
cleaned = arg.strip()
38+
if cleaned in {".", "./"}:
39+
continue
40+
if cleaned.startswith(("/", "~", "../")):
41+
return True
42+
if cleaned == ".." or "/../" in cleaned:
43+
return True
44+
return False
45+
46+
3347
def _validate_bash_command(command: str) -> tuple[bool, str]:
3448
if not command.strip():
3549
return False, "Error: command cannot be empty"
36-
if any(token in command for token in FORBIDDEN_SHELL_TOKENS):
37-
return False, "Error: command contains forbidden shell operators"
50+
if "$(" in command:
51+
return False, "Error: command substitution is not allowed"
52+
if "`" in command:
53+
return False, "Error: backtick command substitution is not allowed"
3854

3955
try:
4056
parsed = shlex.split(command)
@@ -44,23 +60,35 @@ def _validate_bash_command(command: str) -> tuple[bool, str]:
4460
if not parsed:
4561
return False, "Error: command cannot be empty"
4662

63+
if any(token in UNSUPPORTED_SHELL_OPERATORS for token in parsed):
64+
return (
65+
False,
66+
"Error: shell chaining/redirection operators are not supported in run_bash. "
67+
"Run a single command without &&, ||, |, ;, > or <.",
68+
)
69+
4770
executable = parsed[0]
4871
if executable not in READ_ONLY_COMMAND_ALLOWLIST:
4972
allowed = ", ".join(sorted(READ_ONLY_COMMAND_ALLOWLIST))
5073
return False, f"Error: command '{executable}' is not allowed. Allowed commands: {allowed}"
5174

52-
if executable == "git" and len(parsed) > 1:
53-
forbidden_git_subcommands = {"reset", "clean", "checkout", "restore", "rebase", "push", "commit", "merge"}
54-
if parsed[1] in forbidden_git_subcommands:
55-
return False, f"Error: git subcommand '{parsed[1]}' is not allowed"
75+
if _has_unsafe_path_args(parsed):
76+
return False, "Error: command contains unsafe path arguments (absolute, home, or parent traversal)"
77+
78+
if executable == "git":
79+
if len(parsed) < 2:
80+
return False, "Error: git command requires a read-only subcommand"
81+
if parsed[1] not in READ_ONLY_GIT_SUBCOMMANDS:
82+
allowed = ", ".join(sorted(READ_ONLY_GIT_SUBCOMMANDS))
83+
return False, f"Error: git subcommand '{parsed[1]}' is not allowed. Allowed: {allowed}"
5684

5785
return True, "OK"
5886

5987

6088
@tool
6189
def run_bash(command: str, timeout_seconds: int = 10, max_output_chars: int = MAX_BASH_OUTPUT_CHARS) -> str:
6290
"""
63-
Run a single safe read-only bash command from repository root.
91+
Run a single safe read-only command from repository root.
6492
"""
6593
is_valid, reason = _validate_bash_command(command)
6694
if not is_valid:

0 commit comments

Comments
 (0)