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.
Files changed (105) hide show
  1. workpilot/__init__.py +3 -0
  2. workpilot/__main__.py +5 -0
  3. workpilot/agent/__init__.py +0 -0
  4. workpilot/agent/compaction.py +201 -0
  5. workpilot/agent/context.py +323 -0
  6. workpilot/agent/loop.py +545 -0
  7. workpilot/agent/loop_detect.py +154 -0
  8. workpilot/agent/memory.py +885 -0
  9. workpilot/agent/tools/__init__.py +4 -0
  10. workpilot/agent/tools/base.py +70 -0
  11. workpilot/agent/tools/browser.py +660 -0
  12. workpilot/agent/tools/cron.py +176 -0
  13. workpilot/agent/tools/filesystem.py +419 -0
  14. workpilot/agent/tools/git.py +86 -0
  15. workpilot/agent/tools/message.py +107 -0
  16. workpilot/agent/tools/registry.py +120 -0
  17. workpilot/agent/tools/shell.py +95 -0
  18. workpilot/agent/tools/spawn.py +193 -0
  19. workpilot/agent/tools/web.py +587 -0
  20. workpilot/agents/__init__.py +3 -0
  21. workpilot/agents/builtin.py +161 -0
  22. workpilot/bus/__init__.py +11 -0
  23. workpilot/bus/events.py +85 -0
  24. workpilot/bus/queue.py +144 -0
  25. workpilot/channels/__init__.py +4 -0
  26. workpilot/channels/ado.py +133 -0
  27. workpilot/channels/base.py +73 -0
  28. workpilot/channels/cli.py +95 -0
  29. workpilot/channels/cloud.py +601 -0
  30. workpilot/channels/github.py +97 -0
  31. workpilot/channels/manager.py +78 -0
  32. workpilot/channels/outlook.py +94 -0
  33. workpilot/channels/teams.py +106 -0
  34. workpilot/channels/web.py +158 -0
  35. workpilot/cli/__init__.py +0 -0
  36. workpilot/cli/commands.py +1515 -0
  37. workpilot/config/__init__.py +4 -0
  38. workpilot/config/loader.py +165 -0
  39. workpilot/config/schema.py +533 -0
  40. workpilot/cron/__init__.py +5 -0
  41. workpilot/cron/service.py +747 -0
  42. workpilot/gateway/__init__.py +6 -0
  43. workpilot/gateway/auth.py +254 -0
  44. workpilot/gateway/estop_middleware.py +76 -0
  45. workpilot/gateway/middleware.py +116 -0
  46. workpilot/gateway/server.py +953 -0
  47. workpilot/gateway/static/chat.html +1537 -0
  48. workpilot/gateway/static/favicon.svg +22 -0
  49. workpilot/gateway/static/status.html +530 -0
  50. workpilot/graph/__init__.py +20 -0
  51. workpilot/graph/auth.py +165 -0
  52. workpilot/graph/client.py +127 -0
  53. workpilot/graph/mail.py +155 -0
  54. workpilot/graph/teams.py +109 -0
  55. workpilot/graph/tools.py +310 -0
  56. workpilot/heartbeat/__init__.py +5 -0
  57. workpilot/heartbeat/service.py +202 -0
  58. workpilot/hooks/__init__.py +19 -0
  59. workpilot/hooks/handlers.py +250 -0
  60. workpilot/hooks/manager.py +167 -0
  61. workpilot/mcp/__init__.py +15 -0
  62. workpilot/mcp/client.py +278 -0
  63. workpilot/mcp/server.py +293 -0
  64. workpilot/mcp/transport.py +221 -0
  65. workpilot/plugins/__init__.py +0 -0
  66. workpilot/plugins/loader.py +33 -0
  67. workpilot/providers/__init__.py +4 -0
  68. workpilot/providers/base.py +88 -0
  69. workpilot/providers/litellm_provider.py +193 -0
  70. workpilot/providers/router.py +257 -0
  71. workpilot/runtime.py +474 -0
  72. workpilot/security/__init__.py +25 -0
  73. workpilot/security/approval.py +229 -0
  74. workpilot/security/audit.py +163 -0
  75. workpilot/security/classifier.py +291 -0
  76. workpilot/security/credentials.py +107 -0
  77. workpilot/security/estop.py +117 -0
  78. workpilot/security/github_auth.py +218 -0
  79. workpilot/security/leak.py +71 -0
  80. workpilot/security/rbac.py +290 -0
  81. workpilot/security/sandbox.py +111 -0
  82. workpilot/security/sanitizer.py +68 -0
  83. workpilot/security/ssrf.py +101 -0
  84. workpilot/session/__init__.py +3 -0
  85. workpilot/session/manager.py +129 -0
  86. workpilot/skills/__init__.py +5 -0
  87. workpilot/skills/bundled/__init__.py +0 -0
  88. workpilot/skills/bundled/ado.md +16 -0
  89. workpilot/skills/bundled/github.md +18 -0
  90. workpilot/skills/loader.py +280 -0
  91. workpilot/templates/AGENTS.md +20 -0
  92. workpilot/templates/HEARTBEAT.md +16 -0
  93. workpilot/templates/MEMORY.md +6 -0
  94. workpilot/templates/SOUL.md +15 -0
  95. workpilot/templates/TOOLS.md +43 -0
  96. workpilot/templates/USER.md +26 -0
  97. workpilot/utils/__init__.py +0 -0
  98. workpilot/utils/helpers.py +58 -0
  99. workpilot/workflows/__init__.py +13 -0
  100. workpilot/workflows/dev.py +632 -0
  101. workpilot/workflows/office.py +542 -0
  102. workpilot-0.1.5.dist-info/METADATA +44 -0
  103. workpilot-0.1.5.dist-info/RECORD +105 -0
  104. workpilot-0.1.5.dist-info/WHEEL +4 -0
  105. workpilot-0.1.5.dist-info/entry_points.txt +2 -0
workpilot/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """WorkPilot — Enterprise AI assistant with Microsoft ecosystem integration."""
2
+
3
+ __version__ = "0.1.5"
workpilot/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Allow `python -m workpilot`."""
2
+
3
+ from workpilot.cli.commands import app
4
+
5
+ app()
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