Skip to content

Commit eaff072

Browse files
committed
Release v0.8.5: WebSocket CONNECT/INPUT protocol, skills discovery, bash permission matching
- Refactor WebSocket protocol: split INIT into CONNECT (auth) + INPUT (prompt) - Skills plugin discovers .claude/skills/ paths, adds SkillInfo dataclass, setup_skills handler - Bash permission parser validates full subcommands (not just command names) with fnmatch - Prefer-write-tool: soft reminder for file reading instead of hard block - Console shows loaded skills in agent banner - Session registry lifecycle: executing → connected → suspended (replaces running/completed) - Connect client updated for CONNECT → CONNECTED → INPUT protocol - Update tests for new protocol and permission format
1 parent dcca81a commit eaff072

36 files changed

Lines changed: 1817 additions & 631 deletions

VERSIONING.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ Example: `0.0.2`
2626
- Reset MINOR and PATCH to 0
2727
- Reserved for major breaking changes or stable releases
2828

29-
## Current Version: 0.8.4
29+
## Current Version: 0.8.5
3030

3131
### Version History
3232
- 0.0.1b1 → 0.0.1b8 (Beta releases)

connectonion/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
ConnectOnion - A simple agent framework with behavior tracking.
1111
"""
1212

13-
__version__ = "0.8.4"
13+
__version__ = "0.8.5"
1414

1515
# Auto-load .env files for the entire framework
1616
from dotenv import load_dotenv

connectonion/cli/co_ai/agent.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
Plugins included:
1616
- eval: Session persistence for debugging
1717
- system_reminder: Contextual hints
18-
- prefer_write_tool: Nudges toward using Write over Edit
18+
- prefer_write_tool: Block bash file creation, soft-remind for file reading
1919
- tool_approval: Approval flow for dangerous operations
2020
- auto_compact: Context window management
2121
- ulw: Ultra work mode (autonomous N-turn sessions with continuation)
@@ -46,6 +46,7 @@
4646
from .plugins import system_reminder
4747
from connectonion import Agent, bash, TodoList
4848
from connectonion.useful_plugins import eval, tool_approval, auto_compact, prefer_write_tool, ulw, subagents
49+
from connectonion.useful_plugins.skills import skills as skills_plugin
4950

5051

5152
PROMPTS_DIR = Path(__file__).parent / "prompts"
@@ -88,7 +89,7 @@ def create_coding_agent(
8889
system_prompt += f"\n\n---\n\n{project_context}"
8990

9091
# Use SDK's subagents plugin instead of custom task implementation
91-
plugins = [subagents, eval, system_reminder, prefer_write_tool, tool_approval, auto_compact, ulw]
92+
plugins = [skills_plugin, subagents, eval, system_reminder, prefer_write_tool, tool_approval, auto_compact, ulw]
9293

9394
agent = Agent(
9495
name="oo",

connectonion/cli/co_ai/prompts/connectonion/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ Use `load_guide(path)` to load detailed documentation.
9595
| `useful_plugins/tool_approval` | User approval before dangerous tool calls |
9696
| `useful_plugins/auto_compact` | Context window management |
9797
| `useful_plugins/image_result_formatter` | Format base64 images for vision models |
98-
| `useful_plugins/prefer_write_tool` | Nudges toward write over edit for new files |
98+
| `useful_plugins/prefer_write_tool` | Block bash file creation, soft-remind for file reading |
9999
| `useful_plugins/ulw` | Ultra work mode — autonomous multi-turn sessions |
100100
| `useful_plugins/gmail_plugin` | Email approval and CRM sync |
101101
| `useful_plugins/calendar_plugin` | Calendar operation approval |

connectonion/console.py

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,8 @@ def print_banner(
102102
tools: Union[List[str], int] = 0,
103103
log_dir: Optional[str] = None,
104104
llm: Any = None,
105-
balance: Optional[float] = None
105+
balance: Optional[float] = None,
106+
skills: Optional[List[Any]] = None,
106107
) -> None:
107108
"""Print the ConnectOnion banner (Onion Stack style).
108109
@@ -131,8 +132,17 @@ def print_banner(
131132
tools_count = tools
132133
tools_str = f"{tools_count} tool{'s' if tools_count != 1 else ''}" if tools_count else ""
133134

134-
# Build meta line: model · tools
135-
meta_parts = [p for p in [model, tools_str] if p]
135+
# Build skills string
136+
skills_str = ""
137+
if skills:
138+
names = [s.name for s in skills]
139+
if len(names) <= 4:
140+
skills_str = f"{len(names)} skill{'s' if len(names) != 1 else ''} ({', '.join(names)})"
141+
else:
142+
skills_str = f"{len(names)} skills"
143+
144+
# Build meta line: model · tools · skills
145+
meta_parts = [p for p in [model, tools_str, skills_str] if p]
136146
meta_line = " · ".join(meta_parts)
137147

138148
# Check if using OpenOnion managed keys (free credits from Aaron)
@@ -210,6 +220,19 @@ def print_banner(
210220
f.write(f"{line}\n")
211221
f.write("\n")
212222

223+
def print_skills(self, skills: List[Any]) -> None:
224+
"""Print loaded skills after agent banner."""
225+
if not skills:
226+
return
227+
names = ' '.join(f'[cyan]/{s.name}[/cyan]' for s in skills)
228+
_rich_console.print(f" [{DIM_COLOR}]skills:[/{DIM_COLOR}] {names}")
229+
_rich_console.print()
230+
231+
def print_skill_invocation(self, skill_name: str, description: str = "") -> None:
232+
"""Print when a skill is triggered by user /command."""
233+
desc = f" [dim]{description}[/dim]" if description else ""
234+
_rich_console.print(f"{_prefix()} [bold cyan]/{skill_name}[/bold cyan]{desc}")
235+
213236
def print(self, message: str, style: str = None, use_prefix: bool = True):
214237
"""Print message to console and/or log file.
215238

connectonion/core/agent.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ def __init__(
4747
co_dir: Optional[Union[str, Path]] = None
4848
):
4949
self.name = name
50+
self.co_dir = Path(co_dir) if co_dir else Path(".co")
5051
self.system_prompt = load_system_prompt(system_prompt)
5152
self.max_iterations = max_iterations
5253

@@ -154,7 +155,8 @@ def __init__(
154155
model=self.llm.model,
155156
tools=len(self.tools),
156157
log_dir=log_dir,
157-
llm=self.llm
158+
llm=self.llm,
159+
skills=getattr(self, 'skills', []),
158160
)
159161

160162
def _next_trace_id(self) -> str:

connectonion/network/asgi/__init__.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
Dependencies: imports from [asgi/http.py, asgi/websocket.py, time, asyncio] | imported by [network/host/server.py, network/__init__.py] | tested by [tests/network/test_asgi.py]
55
Data flow: create_app(route_handlers, storage, trust, blacklist, whitelist, on_startup, on_shutdown) → returns ASGI app callable → uvicorn calls app(scope, receive, send) → handles lifespan for startup/shutdown → routes to handle_http() or handle_websocket() based on scope type
66
State/Effects: captures start_time for uptime | runs on_startup/on_shutdown callbacks during lifespan | relay connection runs as async task in same event loop
7-
Integration: exposes create_app() factory, handle_http(), handle_websocket(), _pump_messages(), CORS_HEADERS, read_body(), send_json(), send_html(), send_text() | raw ASGI (no FastAPI/Starlette) for protocol control | lifespan support for relay connection
7+
Integration: exposes create_app() factory, handle_http(), handle_websocket(), _pipe_ws_io(), CORS_HEADERS, read_body(), send_json(), send_html(), send_text() | raw ASGI (no FastAPI/Starlette) for protocol control | lifespan support for relay connection
88
Performance: minimal overhead (direct ASGI protocol) | async I/O for concurrency | single event loop for HTTP, WebSocket, and relay
99
Errors: none (errors handled in http.py/websocket.py)
1010
ASGI application for HTTP and WebSocket handling.
@@ -18,7 +18,7 @@
1818
from typing import Callable, Awaitable
1919

2020
from .http import handle_http, send_json, send_html, send_text, read_body, CORS_HEADERS
21-
from .websocket import handle_websocket, _pump_messages
21+
from .websocket import handle_websocket, _pipe_ws_io
2222

2323

2424
def create_app(
@@ -107,7 +107,7 @@ async def app(scope, receive, send):
107107
"create_app",
108108
"handle_http",
109109
"handle_websocket",
110-
"_pump_messages",
110+
"_pipe_ws_io",
111111
"CORS_HEADERS",
112112
"read_body",
113113
"send_json",

connectonion/network/host/routes.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,9 +152,10 @@ def info_handler(agent_metadata: dict, trust, trust_config: dict | None = None,
152152
"name": agent_metadata["name"],
153153
"address": agent_metadata["address"],
154154
"tools": agent_metadata["tools"],
155-
"model": agent_metadata.get("model", "unknown"), # Add model info
155+
"model": agent_metadata.get("model", "unknown"),
156156
"trust": trust.trust, # Extract level string from TrustAgent
157157
"version": __version__,
158+
"skills": agent_metadata.get("skills", []),
158159
"accepted_inputs": {
159160
"text": True,
160161
"images": True,

connectonion/network/host/server.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,10 +103,13 @@ def _extract_agent_metadata(create_agent: Callable) -> tuple[dict, object]:
103103
(metadata dict, sample_agent) - sample_agent for additional extraction
104104
"""
105105
sample = create_agent()
106+
raw_skills = getattr(sample, 'skills', [])
106107
metadata = {
107108
"name": sample.name,
108109
"tools": sample.tools.names(),
109-
"model": sample.llm.model, # Add model to metadata
110+
"model": sample.llm.model,
111+
"skills": [{"name": s.name, "description": s.description, "location": s.location}
112+
for s in raw_skills],
110113
}
111114
return metadata, sample
112115

Lines changed: 49 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,21 @@
11
"""
2-
Purpose: Reject bash file operations and remind agent to use proper tools instead
2+
Purpose: Block bash file creation and soft-remind for file reading
33
LLM-Note:
44
Dependencies: imports from [core/events.py] | imported by [useful_plugins/__init__.py]
5-
Data flow: before_each_tool fires → detect bash file operations → raise ValueError with system reminder
6-
State/Effects: none (rejects tool before execution)
5+
Data flow: before_each_tool fires → block file creation (ValueError) or flag file reading →
6+
after_each_tool fires → append soft reminder to tool result for file reading
7+
State/Effects: sets session['_prefer_read_file_reminder'] flag for soft reminders
78
Integration: exposes prefer_write_tool plugin list
8-
Errors: raises ValueError when bash tries to create or read files
9+
Errors: raises ValueError only for bash file creation
910
10-
Prefer Write Tool Plugin - Block bash file operations, remind to use proper tools.
11+
Prefer Write Tool Plugin - Block bash file creation, soft-remind for file reading.
1112
1213
AI models often use bash commands for file operations:
13-
- Creating: `cat <<EOF > file.py`, `echo > file.py`
14-
- Reading: `cat file.txt`, `head file.txt`, `tail file.txt`
14+
- Creating: `cat <<EOF > file.py`, `echo > file.py` → BLOCKED
15+
- Reading: `cat file.txt`, `head file.txt`, `tail file.txt` → SOFT REMINDER
1516
16-
This is an anti-pattern because:
17-
1. Bypasses proper tool UI/diffs/approval flow
18-
2. Escaping issues with special characters
19-
3. Harder to review and track
20-
4. No line numbers or formatting
21-
22-
This plugin detects these patterns BEFORE execution and rejects with a reminder.
17+
File creation is blocked because it bypasses tool UI/diffs/approval flow.
18+
File reading is allowed but a system reminder suggests using read_file tool instead.
2319
2420
Usage:
2521
from connectonion import Agent
@@ -31,35 +27,34 @@
3127
import re
3228
from typing import TYPE_CHECKING
3329

34-
from ..core.events import before_each_tool
30+
from ..core.events import before_each_tool, after_each_tool
3531

3632
if TYPE_CHECKING:
3733
from ..core.agent import Agent
3834

3935

40-
# Patterns that indicate bash is being used to create/write files
36+
# Patterns that indicate bash is being used to create/write files (hard blocked)
4137
FILE_CREATION_PATTERNS = [
4238
re.compile(r"cat\s+<<"), # cat <<EOF, cat <<'EOF', cat << 'EOF'
4339
re.compile(r">\s*\S+\.\w+\s*<<"), # > file.py <<EOF
44-
re.compile(r"echo\s+.*>\s*\S+"), # echo "..." > file
45-
re.compile(r"printf\s+.*>\s*\S+"), # printf "..." > file
40+
re.compile(r"echo\s+.*[^2]>\s*\S+"), # echo "..." > file (but not 2>)
41+
re.compile(r"printf\s+.*[^2]>\s*\S+"),# printf "..." > file (but not 2>)
4642
re.compile(r"tee\s+\S+"), # tee file.py
47-
re.compile(r">\s*[~/\.]"), # > file, > ./file, > ~/file, > /path
48-
re.compile(r">>\s*[~/\.]"), # >> file, >> ./file, >> ~/file, >> /path
49-
re.compile(r"\s+>\s+\S+"), # cmd > file (with spaces)
50-
re.compile(r"\s+>>\s+\S+"), # cmd >> file (with spaces)
43+
re.compile(r"(?<!\d)>\s*[~\.]"), # > ./file, > ~/file (not 2>/dev/null)
44+
re.compile(r"(?<!\d)>>\s*[~\.]"), # >> ./file, >> ~/file
5145
]
5246

53-
# Patterns that indicate bash is being used to read files
47+
# Patterns that indicate bash is being used to read files (standalone, not in pipelines)
48+
# These trigger a soft reminder, not a hard block
5449
FILE_READING_PATTERNS = [
55-
re.compile(r"^\s*cat\s+\S+"), # cat file.txt (at start of command)
56-
re.compile(r"[;&|]\s*cat\s+\S+"), # ... && cat file.txt
57-
re.compile(r"^\s*head\s+\S+"), # head file.txt
58-
re.compile(r"[;&|]\s*head\s+\S+"), # ... && head file.txt
59-
re.compile(r"^\s*tail\s+\S+"), # tail file.txt
60-
re.compile(r"[;&|]\s*tail\s+\S+"), # ... && tail file.txt
61-
re.compile(r"^\s*less\s+\S+"), # less file.txt
62-
re.compile(r"^\s*more\s+\S+"), # more file.txt
50+
re.compile(r"^\s*cat\s+\S+\s*$"), # cat file.txt (standalone, no pipe)
51+
re.compile(r"[;&]\s*cat\s+\S+\s*$"), # ... && cat file.txt (standalone at end)
52+
re.compile(r"^\s*head\s+\S+"), # head file.txt
53+
re.compile(r"[;&|]\s*head\s+\S+"), # ... && head file.txt
54+
re.compile(r"^\s*tail\s+\S+"), # tail file.txt
55+
re.compile(r"[;&|]\s*tail\s+\S+"), # ... && tail file.txt
56+
re.compile(r"^\s*less\s+\S+"), # less file.txt
57+
re.compile(r"^\s*more\s+\S+"), # more file.txt
6358
]
6459

6560

@@ -81,7 +76,7 @@ def _is_file_reading_command(command: str) -> bool:
8176

8277
@before_each_tool
8378
def block_bash_file_creation(agent: 'Agent') -> None:
84-
"""Block bash commands that create/read files, remind to use proper tools."""
79+
"""Block bash file creation. Flag file reading for soft reminder."""
8580
pending = agent.current_session.get('pending_tool')
8681
if not pending:
8782
return
@@ -92,7 +87,7 @@ def block_bash_file_creation(agent: 'Agent') -> None:
9287

9388
command = pending['arguments'].get('command', '')
9489

95-
# Check for file creation
90+
# File creation → hard block (raises ValueError)
9691
if _is_file_creation_command(command):
9792
if hasattr(agent, 'logger') and agent.logger:
9893
agent.logger.print("[yellow]⚠ Blocked bash file creation. Use Write tool instead.[/yellow]")
@@ -119,30 +114,30 @@ def block_bash_file_creation(agent: 'Agent') -> None:
119114
"</system-reminder>"
120115
)
121116

122-
# Check for file reading
117+
# Check for file reading — soft flag, don't block
123118
if _is_file_reading_command(command):
119+
agent.current_session['_prefer_read_file_reminder'] = True
124120
if hasattr(agent, 'logger') and agent.logger:
125-
agent.logger.print("[yellow]⚠ Blocked bash file reading. Use read_file tool instead.[/yellow]")
121+
agent.logger.print("[yellow]⚠ Consider using read_file tool instead of bash for reading files.[/yellow]")
126122

127-
if agent.io:
128-
agent.io.send({
129-
'type': 'tool_blocked',
130-
'tool': tool_name,
131-
'reason': 'file_reading',
132-
'message': 'Use read_file tool instead of bash for reading files',
133-
'command': command,
134-
})
135123

136-
raise ValueError(
137-
"Bash file reading blocked."
138-
"\n\n<system-reminder>"
139-
"You tried to read a file using bash (cat, head, tail, etc). This is blocked.\n\n"
140-
"Use the read_file tool instead:\n"
141-
" read_file(file_path=\"/path/to/file.txt\")\n\n"
142-
"Why: read_file provides line numbers, proper formatting, and better control.\n"
143-
"Do NOT retry with bash. Use read_file tool now."
144-
"</system-reminder>"
145-
)
124+
@after_each_tool
125+
def remind_read_file(agent: 'Agent') -> None:
126+
"""Append soft system reminder to tool result when bash was used to read files."""
127+
if not agent.current_session.pop('_prefer_read_file_reminder', False):
128+
return
129+
130+
messages = agent.current_session.get('messages', [])
131+
for msg in reversed(messages):
132+
if msg.get('role') == 'tool':
133+
msg['content'] = msg.get('content', '') + (
134+
"\n\n<system-reminder>"
135+
"You used bash to read a file. Consider using the read_file tool instead:\n"
136+
" read_file(file_path=\"/path/to/file.txt\")\n\n"
137+
"Why: read_file provides line numbers, proper formatting, and better control."
138+
"</system-reminder>"
139+
)
140+
break
146141

147142

148-
prefer_write_tool = [block_bash_file_creation]
143+
prefer_write_tool = [block_bash_file_creation, remind_read_file]

0 commit comments

Comments
 (0)