workpilot 0.1.5__py3-none-any.whl
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.
- workpilot/__init__.py +3 -0
- workpilot/__main__.py +5 -0
- workpilot/agent/__init__.py +0 -0
- workpilot/agent/compaction.py +201 -0
- workpilot/agent/context.py +323 -0
- workpilot/agent/loop.py +545 -0
- workpilot/agent/loop_detect.py +154 -0
- workpilot/agent/memory.py +885 -0
- workpilot/agent/tools/__init__.py +4 -0
- workpilot/agent/tools/base.py +70 -0
- workpilot/agent/tools/browser.py +660 -0
- workpilot/agent/tools/cron.py +176 -0
- workpilot/agent/tools/filesystem.py +419 -0
- workpilot/agent/tools/git.py +86 -0
- workpilot/agent/tools/message.py +107 -0
- workpilot/agent/tools/registry.py +120 -0
- workpilot/agent/tools/shell.py +95 -0
- workpilot/agent/tools/spawn.py +193 -0
- workpilot/agent/tools/web.py +587 -0
- workpilot/agents/__init__.py +3 -0
- workpilot/agents/builtin.py +161 -0
- workpilot/bus/__init__.py +11 -0
- workpilot/bus/events.py +85 -0
- workpilot/bus/queue.py +144 -0
- workpilot/channels/__init__.py +4 -0
- workpilot/channels/ado.py +133 -0
- workpilot/channels/base.py +73 -0
- workpilot/channels/cli.py +95 -0
- workpilot/channels/cloud.py +601 -0
- workpilot/channels/github.py +97 -0
- workpilot/channels/manager.py +78 -0
- workpilot/channels/outlook.py +94 -0
- workpilot/channels/teams.py +106 -0
- workpilot/channels/web.py +158 -0
- workpilot/cli/__init__.py +0 -0
- workpilot/cli/commands.py +1515 -0
- workpilot/config/__init__.py +4 -0
- workpilot/config/loader.py +165 -0
- workpilot/config/schema.py +533 -0
- workpilot/cron/__init__.py +5 -0
- workpilot/cron/service.py +747 -0
- workpilot/gateway/__init__.py +6 -0
- workpilot/gateway/auth.py +254 -0
- workpilot/gateway/estop_middleware.py +76 -0
- workpilot/gateway/middleware.py +116 -0
- workpilot/gateway/server.py +953 -0
- workpilot/gateway/static/chat.html +1537 -0
- workpilot/gateway/static/favicon.svg +22 -0
- workpilot/gateway/static/status.html +530 -0
- workpilot/graph/__init__.py +20 -0
- workpilot/graph/auth.py +165 -0
- workpilot/graph/client.py +127 -0
- workpilot/graph/mail.py +155 -0
- workpilot/graph/teams.py +109 -0
- workpilot/graph/tools.py +310 -0
- workpilot/heartbeat/__init__.py +5 -0
- workpilot/heartbeat/service.py +202 -0
- workpilot/hooks/__init__.py +19 -0
- workpilot/hooks/handlers.py +250 -0
- workpilot/hooks/manager.py +167 -0
- workpilot/mcp/__init__.py +15 -0
- workpilot/mcp/client.py +278 -0
- workpilot/mcp/server.py +293 -0
- workpilot/mcp/transport.py +221 -0
- workpilot/plugins/__init__.py +0 -0
- workpilot/plugins/loader.py +33 -0
- workpilot/providers/__init__.py +4 -0
- workpilot/providers/base.py +88 -0
- workpilot/providers/litellm_provider.py +193 -0
- workpilot/providers/router.py +257 -0
- workpilot/runtime.py +474 -0
- workpilot/security/__init__.py +25 -0
- workpilot/security/approval.py +229 -0
- workpilot/security/audit.py +163 -0
- workpilot/security/classifier.py +291 -0
- workpilot/security/credentials.py +107 -0
- workpilot/security/estop.py +117 -0
- workpilot/security/github_auth.py +218 -0
- workpilot/security/leak.py +71 -0
- workpilot/security/rbac.py +290 -0
- workpilot/security/sandbox.py +111 -0
- workpilot/security/sanitizer.py +68 -0
- workpilot/security/ssrf.py +101 -0
- workpilot/session/__init__.py +3 -0
- workpilot/session/manager.py +129 -0
- workpilot/skills/__init__.py +5 -0
- workpilot/skills/bundled/__init__.py +0 -0
- workpilot/skills/bundled/ado.md +16 -0
- workpilot/skills/bundled/github.md +18 -0
- workpilot/skills/loader.py +280 -0
- workpilot/templates/AGENTS.md +20 -0
- workpilot/templates/HEARTBEAT.md +16 -0
- workpilot/templates/MEMORY.md +6 -0
- workpilot/templates/SOUL.md +15 -0
- workpilot/templates/TOOLS.md +43 -0
- workpilot/templates/USER.md +26 -0
- workpilot/utils/__init__.py +0 -0
- workpilot/utils/helpers.py +58 -0
- workpilot/workflows/__init__.py +13 -0
- workpilot/workflows/dev.py +632 -0
- workpilot/workflows/office.py +542 -0
- workpilot-0.1.5.dist-info/METADATA +44 -0
- workpilot-0.1.5.dist-info/RECORD +105 -0
- workpilot-0.1.5.dist-info/WHEEL +4 -0
- workpilot-0.1.5.dist-info/entry_points.txt +2 -0
workpilot/__init__.py
ADDED
workpilot/__main__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Context compaction — 3-tier context window management.
|
|
3
|
+
|
|
4
|
+
Prevents context overflow by progressively summarizing older messages:
|
|
5
|
+
- Tier 1 (80%): summarize older messages with fast model, keep recent 10
|
|
6
|
+
- Tier 2 (85%): aggressive summary, keep recent 5
|
|
7
|
+
- Tier 3 (95%): truncate oldest messages (no LLM call), keep recent 3
|
|
8
|
+
|
|
9
|
+
MEMORY.md (facts) is never compacted — bounded by design (8KB).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from enum import Enum
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from loguru import logger
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CompactionTier(str, Enum):
|
|
21
|
+
NONE = "none"
|
|
22
|
+
TIER1 = "tier1" # 80% — summarize, keep 10
|
|
23
|
+
TIER2 = "tier2" # 85% — aggressive, keep 5
|
|
24
|
+
TIER3 = "tier3" # 95% — truncate, keep 3
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# Token estimation: word count × 1.3 (IronClaw heuristic)
|
|
28
|
+
_TOKEN_MULTIPLIER = 1.3
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def estimate_tokens(messages: list[dict[str, Any]]) -> int:
|
|
32
|
+
"""Estimate token count from messages using word-count heuristic."""
|
|
33
|
+
total_words = 0
|
|
34
|
+
for msg in messages:
|
|
35
|
+
content = msg.get("content") or ""
|
|
36
|
+
if isinstance(content, str):
|
|
37
|
+
total_words += len(content.split())
|
|
38
|
+
# Count tool_calls as ~50 words each
|
|
39
|
+
tool_calls = msg.get("tool_calls", [])
|
|
40
|
+
total_words += len(tool_calls) * 50
|
|
41
|
+
return int(total_words * _TOKEN_MULTIPLIER)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def check_threshold(
|
|
45
|
+
messages: list[dict[str, Any]],
|
|
46
|
+
context_limit: int,
|
|
47
|
+
) -> CompactionTier:
|
|
48
|
+
"""Determine which compaction tier applies based on estimated usage."""
|
|
49
|
+
tokens = estimate_tokens(messages)
|
|
50
|
+
usage = tokens / context_limit if context_limit > 0 else 0.0
|
|
51
|
+
|
|
52
|
+
if usage >= 0.95:
|
|
53
|
+
return CompactionTier.TIER3
|
|
54
|
+
elif usage >= 0.85:
|
|
55
|
+
return CompactionTier.TIER2
|
|
56
|
+
elif usage >= 0.80:
|
|
57
|
+
return CompactionTier.TIER1
|
|
58
|
+
return CompactionTier.NONE
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def compact_tier3(messages: list[dict[str, Any]], keep_recent: int = 3) -> list[dict[str, Any]]:
|
|
62
|
+
"""Tier 3: truncate oldest messages, no LLM call. Keep system + recent N."""
|
|
63
|
+
system_msgs = [m for m in messages if m.get("role") == "system"]
|
|
64
|
+
non_system = [m for m in messages if m.get("role") != "system"]
|
|
65
|
+
|
|
66
|
+
if len(non_system) <= keep_recent:
|
|
67
|
+
return messages
|
|
68
|
+
|
|
69
|
+
kept = non_system[-keep_recent:]
|
|
70
|
+
logger.info(
|
|
71
|
+
"Compaction tier 3: truncated {} messages, kept {} recent",
|
|
72
|
+
len(non_system) - keep_recent,
|
|
73
|
+
keep_recent,
|
|
74
|
+
)
|
|
75
|
+
return (
|
|
76
|
+
system_msgs
|
|
77
|
+
+ [
|
|
78
|
+
{
|
|
79
|
+
"role": "system",
|
|
80
|
+
"content": f"[Context compacted: {len(non_system) - keep_recent} older messages removed]",
|
|
81
|
+
}
|
|
82
|
+
]
|
|
83
|
+
+ kept
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def build_summary_prompt(messages_to_summarize: list[dict[str, Any]]) -> str:
|
|
88
|
+
"""Build a prompt for LLM to summarize older messages."""
|
|
89
|
+
lines = []
|
|
90
|
+
for msg in messages_to_summarize:
|
|
91
|
+
role = msg.get("role", "unknown")
|
|
92
|
+
content = msg.get("content") or ""
|
|
93
|
+
if isinstance(content, str) and content:
|
|
94
|
+
preview = content[:200] + ("..." if len(content) > 200 else "")
|
|
95
|
+
lines.append(f"[{role}] {preview}")
|
|
96
|
+
conversation = "\n".join(lines)
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
"Summarize the following conversation concisely. Focus on:\n"
|
|
100
|
+
"- Key decisions made\n"
|
|
101
|
+
"- Important information exchanged (file paths, code patterns, error messages)\n"
|
|
102
|
+
"- Actions taken and their outcomes\n"
|
|
103
|
+
"- Pending tasks or open questions\n"
|
|
104
|
+
"Preserve specific details that would prevent duplicate work.\n\n"
|
|
105
|
+
f"{conversation}\n\n"
|
|
106
|
+
"Summary:"
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
async def compact_with_llm(
|
|
111
|
+
messages: list[dict[str, Any]],
|
|
112
|
+
provider: Any,
|
|
113
|
+
model: str,
|
|
114
|
+
keep_recent: int,
|
|
115
|
+
) -> list[dict[str, Any]]:
|
|
116
|
+
"""Compact by summarizing older messages with an LLM call.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
messages: Full message list.
|
|
120
|
+
provider: LLMProvider instance.
|
|
121
|
+
model: Model to use for summarization.
|
|
122
|
+
keep_recent: Number of recent non-system messages to preserve.
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
Compacted message list with summary replacing older messages.
|
|
126
|
+
"""
|
|
127
|
+
system_msgs = [m for m in messages if m.get("role") == "system"]
|
|
128
|
+
non_system = [m for m in messages if m.get("role") != "system"]
|
|
129
|
+
|
|
130
|
+
if len(non_system) <= keep_recent:
|
|
131
|
+
return messages
|
|
132
|
+
|
|
133
|
+
to_summarize = non_system[:-keep_recent]
|
|
134
|
+
to_keep = non_system[-keep_recent:]
|
|
135
|
+
|
|
136
|
+
prompt = build_summary_prompt(to_summarize)
|
|
137
|
+
try:
|
|
138
|
+
response = await provider.complete(
|
|
139
|
+
model=model,
|
|
140
|
+
messages=[{"role": "user", "content": prompt}],
|
|
141
|
+
temperature=0.1,
|
|
142
|
+
max_tokens=1024,
|
|
143
|
+
)
|
|
144
|
+
summary = response.content
|
|
145
|
+
logger.info(
|
|
146
|
+
"Compaction: summarized {} messages into {} chars, kept {} recent",
|
|
147
|
+
len(to_summarize),
|
|
148
|
+
len(summary),
|
|
149
|
+
keep_recent,
|
|
150
|
+
)
|
|
151
|
+
except Exception as exc:
|
|
152
|
+
logger.error("Compaction LLM call failed: {}, falling back to truncation", exc)
|
|
153
|
+
return compact_tier3(messages, keep_recent=keep_recent)
|
|
154
|
+
|
|
155
|
+
return (
|
|
156
|
+
system_msgs
|
|
157
|
+
+ [
|
|
158
|
+
{
|
|
159
|
+
"role": "system",
|
|
160
|
+
"content": f"[Conversation summary — {len(to_summarize)} messages compacted]\n{summary}",
|
|
161
|
+
}
|
|
162
|
+
]
|
|
163
|
+
+ to_keep
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class ContextCompactor:
|
|
168
|
+
"""Manages context window compaction for the agent loop.
|
|
169
|
+
|
|
170
|
+
Call ``maybe_compact()`` before each LLM call. It checks estimated
|
|
171
|
+
token usage against the context limit and applies the appropriate
|
|
172
|
+
compaction tier.
|
|
173
|
+
"""
|
|
174
|
+
|
|
175
|
+
def __init__(
|
|
176
|
+
self,
|
|
177
|
+
context_limit: int = 128_000,
|
|
178
|
+
provider: Any = None,
|
|
179
|
+
model: str = "auto",
|
|
180
|
+
) -> None:
|
|
181
|
+
self._context_limit = context_limit
|
|
182
|
+
self._provider = provider
|
|
183
|
+
self._model = model
|
|
184
|
+
|
|
185
|
+
async def maybe_compact(self, messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
186
|
+
"""Check and apply compaction if needed. Returns (possibly compacted) messages."""
|
|
187
|
+
tier = check_threshold(messages, self._context_limit)
|
|
188
|
+
|
|
189
|
+
if tier == CompactionTier.NONE:
|
|
190
|
+
return messages
|
|
191
|
+
|
|
192
|
+
if tier == CompactionTier.TIER3:
|
|
193
|
+
return compact_tier3(messages, keep_recent=3)
|
|
194
|
+
|
|
195
|
+
if self._provider is None:
|
|
196
|
+
# No provider for LLM summarization — fall back to truncation
|
|
197
|
+
keep = 5 if tier == CompactionTier.TIER2 else 10
|
|
198
|
+
return compact_tier3(messages, keep_recent=keep)
|
|
199
|
+
|
|
200
|
+
keep = 5 if tier == CompactionTier.TIER2 else 10
|
|
201
|
+
return await compact_with_llm(messages, self._provider, self._model, keep_recent=keep)
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Context builder — assembles the system prompt from configuration,
|
|
3
|
+
workspace identity files, skills, and memory.
|
|
4
|
+
|
|
5
|
+
Build order (redesigned, following nanobot + OpenClaw):
|
|
6
|
+
1. Core identity block (name, runtime, workspace, guidelines)
|
|
7
|
+
2. SOUL.md (personality)
|
|
8
|
+
3. Config instructions
|
|
9
|
+
4. USER.md (preferences)
|
|
10
|
+
5. AGENTS.md (multi-agent rules)
|
|
11
|
+
6. TOOLS.md (tool usage notes)
|
|
12
|
+
7. Long-term memory (MEMORY.md, capped at 8KB)
|
|
13
|
+
8. Always-loaded skills (full content)
|
|
14
|
+
9. Skills summary (XML progressive disclosure)
|
|
15
|
+
10. Sub-agent discovery table
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import platform
|
|
21
|
+
import sys
|
|
22
|
+
from datetime import UTC, datetime
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any, Literal
|
|
25
|
+
|
|
26
|
+
from workpilot.config.schema import AgentConfig
|
|
27
|
+
|
|
28
|
+
_SECTION_SEPARATOR = "\n\n---\n\n"
|
|
29
|
+
|
|
30
|
+
_MEMORY_CAP = 8192 # 8KB cap for MEMORY.md content
|
|
31
|
+
|
|
32
|
+
PromptMode = Literal["full", "minimal"]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ContextBuilder:
|
|
36
|
+
"""Builds the system prompt for an agent from config + workspace files + skills."""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
agent_config: AgentConfig,
|
|
41
|
+
workspace: Path,
|
|
42
|
+
skills: list[Any] | None = None,
|
|
43
|
+
available_agents: dict[str, AgentConfig] | None = None,
|
|
44
|
+
) -> None:
|
|
45
|
+
self._config = agent_config
|
|
46
|
+
self._workspace = workspace
|
|
47
|
+
self._skills = skills or []
|
|
48
|
+
self._available_agents = available_agents or {}
|
|
49
|
+
|
|
50
|
+
def set_skills(self, skills: list[Any]) -> None:
|
|
51
|
+
"""Update the loaded skills."""
|
|
52
|
+
self._skills = skills
|
|
53
|
+
|
|
54
|
+
def build_system_prompt(self, mode: PromptMode = "full") -> str:
|
|
55
|
+
"""Assemble the system prompt following priority order.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
mode: ``"full"`` for the main agent (all sections),
|
|
59
|
+
``"minimal"`` for sub-agents (identity + config + TOOLS.md only).
|
|
60
|
+
"""
|
|
61
|
+
parts: list[str] = []
|
|
62
|
+
|
|
63
|
+
# 1. Core identity block — always included
|
|
64
|
+
parts.append(self._build_identity())
|
|
65
|
+
|
|
66
|
+
if mode == "minimal":
|
|
67
|
+
# Minimal mode: identity + config instructions + TOOLS.md
|
|
68
|
+
if self._config.instructions:
|
|
69
|
+
parts.append(self._config.instructions)
|
|
70
|
+
|
|
71
|
+
tools_content = self._load_tools_md()
|
|
72
|
+
if tools_content:
|
|
73
|
+
parts.append(tools_content)
|
|
74
|
+
|
|
75
|
+
return _SECTION_SEPARATOR.join(parts)
|
|
76
|
+
|
|
77
|
+
# ── Full mode: all sections ──────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
# 2. SOUL.md — agent personality/identity
|
|
80
|
+
soul_path = self._resolve_identity_file(self._config.soul_file, "SOUL.md")
|
|
81
|
+
if soul_path and soul_path.is_file():
|
|
82
|
+
content = soul_path.read_text(encoding="utf-8").strip()
|
|
83
|
+
if content:
|
|
84
|
+
parts.append(f"## Identity\n\n{content}")
|
|
85
|
+
|
|
86
|
+
# 3. Base instructions from config
|
|
87
|
+
if self._config.instructions:
|
|
88
|
+
parts.append(self._config.instructions)
|
|
89
|
+
|
|
90
|
+
# 4. USER.md — learned user preferences
|
|
91
|
+
user_path = self._resolve_identity_file(self._config.user_file, "USER.md")
|
|
92
|
+
if user_path and user_path.is_file():
|
|
93
|
+
content = user_path.read_text(encoding="utf-8").strip()
|
|
94
|
+
if content:
|
|
95
|
+
parts.append(f"## User Preferences\n\n{content}")
|
|
96
|
+
|
|
97
|
+
# 5. AGENTS.md — multi-agent rules
|
|
98
|
+
agents_path = self._resolve_identity_file(self._config.agents_file, "AGENTS.md")
|
|
99
|
+
if agents_path and agents_path.is_file():
|
|
100
|
+
content = agents_path.read_text(encoding="utf-8").strip()
|
|
101
|
+
if content:
|
|
102
|
+
parts.append(f"## Agent Rules\n\n{content}")
|
|
103
|
+
|
|
104
|
+
# 6. TOOLS.md — tool usage notes
|
|
105
|
+
tools_content = self._load_tools_md()
|
|
106
|
+
if tools_content:
|
|
107
|
+
parts.append(tools_content)
|
|
108
|
+
|
|
109
|
+
# 7. Long-term memory (MEMORY.md, capped at 8KB)
|
|
110
|
+
# Shared memory
|
|
111
|
+
shared_memory = self._workspace / "memory" / "shared" / "MEMORY.md"
|
|
112
|
+
if shared_memory.is_file():
|
|
113
|
+
content = shared_memory.read_text(encoding="utf-8").strip()
|
|
114
|
+
if content:
|
|
115
|
+
parts.append(f"## Shared Memory\n\n{content[:_MEMORY_CAP]}")
|
|
116
|
+
|
|
117
|
+
# Agent-specific memory
|
|
118
|
+
memory_path = self._workspace / "MEMORY.md"
|
|
119
|
+
if memory_path.is_file():
|
|
120
|
+
memory = memory_path.read_text(encoding="utf-8").strip()
|
|
121
|
+
if memory:
|
|
122
|
+
parts.append(f"## Long-term Memory\n\n{memory[:_MEMORY_CAP]}")
|
|
123
|
+
|
|
124
|
+
# 8. Always-loaded skills (full content)
|
|
125
|
+
for skill in self._skills:
|
|
126
|
+
if hasattr(skill, "mode") and skill.mode == "always" and hasattr(skill, "content"):
|
|
127
|
+
parts.append(f"## Skill: {skill.name}\n\n{skill.content}")
|
|
128
|
+
|
|
129
|
+
# 9. Skills summary (XML progressive disclosure)
|
|
130
|
+
skills_xml = self._build_skills_summary()
|
|
131
|
+
if skills_xml:
|
|
132
|
+
parts.append(skills_xml)
|
|
133
|
+
|
|
134
|
+
# 10. Available sub-agents discovery table
|
|
135
|
+
agent_table = self._build_agent_discovery()
|
|
136
|
+
if agent_table:
|
|
137
|
+
parts.append(agent_table)
|
|
138
|
+
|
|
139
|
+
return _SECTION_SEPARATOR.join(parts)
|
|
140
|
+
|
|
141
|
+
def build_messages(
|
|
142
|
+
self,
|
|
143
|
+
history: list[dict[str, Any]],
|
|
144
|
+
user_message: str,
|
|
145
|
+
*,
|
|
146
|
+
mode: PromptMode = "full",
|
|
147
|
+
channel: str | None = None,
|
|
148
|
+
channel_id: str | None = None,
|
|
149
|
+
) -> list[dict[str, Any]]:
|
|
150
|
+
"""Build the full message list for the LLM call.
|
|
151
|
+
|
|
152
|
+
Runtime context is prepended to the user message (not the system prompt)
|
|
153
|
+
to avoid cache-busting the system prompt on every turn.
|
|
154
|
+
"""
|
|
155
|
+
messages: list[dict[str, Any]] = []
|
|
156
|
+
|
|
157
|
+
# System prompt
|
|
158
|
+
system_prompt = self.build_system_prompt(mode=mode)
|
|
159
|
+
if system_prompt:
|
|
160
|
+
messages.append({"role": "system", "content": system_prompt})
|
|
161
|
+
|
|
162
|
+
# Conversation history
|
|
163
|
+
messages.extend(history)
|
|
164
|
+
|
|
165
|
+
# Current user message with runtime context prepended
|
|
166
|
+
runtime_ctx = self._build_runtime_context(channel=channel, channel_id=channel_id)
|
|
167
|
+
if runtime_ctx:
|
|
168
|
+
content = f"{runtime_ctx}\n\n{user_message}"
|
|
169
|
+
else:
|
|
170
|
+
content = user_message
|
|
171
|
+
messages.append({"role": "user", "content": content})
|
|
172
|
+
|
|
173
|
+
return messages
|
|
174
|
+
|
|
175
|
+
# ── Private builders ──────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
def _build_identity(self) -> str:
|
|
178
|
+
"""Build the core identity block with runtime and workspace info."""
|
|
179
|
+
plat = platform.system()
|
|
180
|
+
arch = platform.machine()
|
|
181
|
+
py_ver = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
|
|
182
|
+
|
|
183
|
+
return (
|
|
184
|
+
"# WorkPilot\n\n"
|
|
185
|
+
"You are WorkPilot, an enterprise AI coding and office assistant.\n\n"
|
|
186
|
+
"## Runtime\n\n"
|
|
187
|
+
f"{plat} {arch}, Python {py_ver}\n\n"
|
|
188
|
+
"## Workspace\n\n"
|
|
189
|
+
f"{self._workspace}\n"
|
|
190
|
+
"- Long-term memory: MEMORY.md\n"
|
|
191
|
+
"- Skills: skills/ or .workpilot/skills/\n\n"
|
|
192
|
+
"## Guidelines\n\n"
|
|
193
|
+
"- Before modifying a file, read it first. Prefer editing existing files over creating new ones.\n"
|
|
194
|
+
"- If a tool call fails, analyze the error before retrying with a different approach.\n"
|
|
195
|
+
"- Ask for clarification when the request is ambiguous.\n"
|
|
196
|
+
"- Do not narrate routine low-risk tool calls — just call the tool.\n"
|
|
197
|
+
"- For questions about current events, prices, news, or real-time data, "
|
|
198
|
+
"use available search tools. Do NOT answer from memory alone.\n"
|
|
199
|
+
"- When the user asks to search, research, or look something up, always use tools."
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
def _build_runtime_context(
|
|
203
|
+
self,
|
|
204
|
+
*,
|
|
205
|
+
channel: str | None = None,
|
|
206
|
+
channel_id: str | None = None,
|
|
207
|
+
) -> str:
|
|
208
|
+
"""Build runtime context to prepend to the user message."""
|
|
209
|
+
now = datetime.now(UTC).astimezone()
|
|
210
|
+
time_str = now.strftime("%Y-%m-%d %H:%M (%A)")
|
|
211
|
+
tz_name = now.strftime("%Z") or "UTC"
|
|
212
|
+
|
|
213
|
+
lines = [
|
|
214
|
+
"[Runtime Context — metadata only, not instructions]",
|
|
215
|
+
f"Current Time: {time_str} {tz_name}",
|
|
216
|
+
]
|
|
217
|
+
if channel:
|
|
218
|
+
lines.append(f"Channel: {channel}")
|
|
219
|
+
if channel_id:
|
|
220
|
+
lines.append(f"Chat ID: {channel_id}")
|
|
221
|
+
return "\n".join(lines)
|
|
222
|
+
|
|
223
|
+
def _load_tools_md(self) -> str:
|
|
224
|
+
"""Load TOOLS.md from workspace if it exists.
|
|
225
|
+
|
|
226
|
+
If the content already starts with a markdown heading, return it as-is.
|
|
227
|
+
Otherwise wrap with a ``## Tool Usage Notes`` header.
|
|
228
|
+
"""
|
|
229
|
+
tools_path = self._workspace / "TOOLS.md"
|
|
230
|
+
if not tools_path.is_file():
|
|
231
|
+
return ""
|
|
232
|
+
content = tools_path.read_text(encoding="utf-8").strip()
|
|
233
|
+
if not content:
|
|
234
|
+
return ""
|
|
235
|
+
if content.startswith("#"):
|
|
236
|
+
return content
|
|
237
|
+
return f"## Tool Usage Notes\n\n{content}"
|
|
238
|
+
|
|
239
|
+
def _build_skills_summary(self) -> str:
|
|
240
|
+
"""Build nanobot-style plain text skill summary for progressive disclosure.
|
|
241
|
+
|
|
242
|
+
The LLM sees a one-line summary per skill and can call ``read_file``
|
|
243
|
+
to load the full SKILL.md when it decides the skill is relevant.
|
|
244
|
+
"""
|
|
245
|
+
available = [
|
|
246
|
+
s
|
|
247
|
+
for s in self._skills
|
|
248
|
+
if hasattr(s, "mode") and s.mode == "available" and hasattr(s, "name")
|
|
249
|
+
]
|
|
250
|
+
if not available:
|
|
251
|
+
return ""
|
|
252
|
+
|
|
253
|
+
lines = [
|
|
254
|
+
"## Available Skills",
|
|
255
|
+
"",
|
|
256
|
+
"Use read_file to load a skill's full instructions when relevant.",
|
|
257
|
+
"",
|
|
258
|
+
]
|
|
259
|
+
for skill in available:
|
|
260
|
+
desc = getattr(skill, "description", "") or ""
|
|
261
|
+
path = getattr(skill, "source_path", "") or ""
|
|
262
|
+
# Check requirements (cache to avoid repeated shutil.which lookups)
|
|
263
|
+
if hasattr(skill, "check_requirements"):
|
|
264
|
+
cached = getattr(skill, "_cached_missing", None)
|
|
265
|
+
if cached is None:
|
|
266
|
+
missing = skill.check_requirements()
|
|
267
|
+
try:
|
|
268
|
+
skill._cached_missing = missing # type: ignore[union-attr]
|
|
269
|
+
except (AttributeError, TypeError):
|
|
270
|
+
pass
|
|
271
|
+
else:
|
|
272
|
+
missing = cached
|
|
273
|
+
else:
|
|
274
|
+
missing = []
|
|
275
|
+
suffix = ""
|
|
276
|
+
if missing:
|
|
277
|
+
suffix = f" [unavailable: missing {', '.join(missing)}]"
|
|
278
|
+
elif path:
|
|
279
|
+
suffix = f" ({path})"
|
|
280
|
+
lines.append(f"- {skill.name}: {desc}{suffix}")
|
|
281
|
+
return "\n".join(lines)
|
|
282
|
+
|
|
283
|
+
def _build_agent_discovery(self) -> str:
|
|
284
|
+
"""Build the Available Sub-Agents table for the default agent's context."""
|
|
285
|
+
if not self._available_agents:
|
|
286
|
+
return ""
|
|
287
|
+
|
|
288
|
+
lines = [
|
|
289
|
+
"## Available Sub-Agents",
|
|
290
|
+
"",
|
|
291
|
+
"You can delegate tasks using the `spawn` tool.",
|
|
292
|
+
"",
|
|
293
|
+
"| Agent | Description | Model | Tools | Budget |",
|
|
294
|
+
"|-------|------------|-------|-------|--------|",
|
|
295
|
+
]
|
|
296
|
+
for name, cfg in self._available_agents.items():
|
|
297
|
+
# Skip internal agents (security) and self (default)
|
|
298
|
+
if cfg.metadata.get("internal") or name == "default":
|
|
299
|
+
continue
|
|
300
|
+
tools_str = ", ".join(cfg.tools[:4])
|
|
301
|
+
if len(cfg.tools) > 4:
|
|
302
|
+
tools_str += f" (+{len(cfg.tools) - 4})"
|
|
303
|
+
lines.append(
|
|
304
|
+
f"| {name} | {cfg.description} | {cfg.model} "
|
|
305
|
+
f"| {tools_str} | {cfg.max_iterations} iter |"
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
# Only return if there are non-internal, non-default agents
|
|
309
|
+
if len(lines) <= 6: # just header, no rows
|
|
310
|
+
return ""
|
|
311
|
+
return "\n".join(lines)
|
|
312
|
+
|
|
313
|
+
def _resolve_identity_file(self, config_path: str | None, default_name: str) -> Path | None:
|
|
314
|
+
"""Resolve an identity file path from config or workspace default."""
|
|
315
|
+
if config_path:
|
|
316
|
+
p = Path(config_path)
|
|
317
|
+
if p.is_absolute():
|
|
318
|
+
return p
|
|
319
|
+
return self._workspace / p
|
|
320
|
+
candidate = self._workspace / default_name
|
|
321
|
+
if candidate.is_file():
|
|
322
|
+
return candidate
|
|
323
|
+
return None
|