tsugite-cli 0.3.3__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 (101) hide show
  1. tsugite/__init__.py +6 -0
  2. tsugite/agent_composition.py +163 -0
  3. tsugite/agent_inheritance.py +479 -0
  4. tsugite/agent_preparation.py +236 -0
  5. tsugite/agent_runner/__init__.py +45 -0
  6. tsugite/agent_runner/helpers.py +106 -0
  7. tsugite/agent_runner/history_integration.py +248 -0
  8. tsugite/agent_runner/metrics.py +100 -0
  9. tsugite/agent_runner/runner.py +1879 -0
  10. tsugite/agent_runner/validation.py +70 -0
  11. tsugite/agent_utils.py +167 -0
  12. tsugite/attachments/__init__.py +65 -0
  13. tsugite/attachments/auto_context.py +199 -0
  14. tsugite/attachments/base.py +34 -0
  15. tsugite/attachments/file.py +51 -0
  16. tsugite/attachments/inline.py +31 -0
  17. tsugite/attachments/storage.py +178 -0
  18. tsugite/attachments/url.py +59 -0
  19. tsugite/attachments/youtube.py +101 -0
  20. tsugite/benchmark/__init__.py +62 -0
  21. tsugite/benchmark/config.py +183 -0
  22. tsugite/benchmark/core.py +292 -0
  23. tsugite/benchmark/discovery.py +377 -0
  24. tsugite/benchmark/evaluators.py +671 -0
  25. tsugite/benchmark/execution.py +657 -0
  26. tsugite/benchmark/metrics.py +204 -0
  27. tsugite/benchmark/reports.py +420 -0
  28. tsugite/benchmark/utils.py +288 -0
  29. tsugite/builtin_agents/chat-assistant.md +53 -0
  30. tsugite/builtin_agents/default.md +140 -0
  31. tsugite/builtin_agents.py +5 -0
  32. tsugite/cache.py +195 -0
  33. tsugite/cli/__init__.py +1042 -0
  34. tsugite/cli/agents.py +148 -0
  35. tsugite/cli/attachments.py +193 -0
  36. tsugite/cli/benchmark.py +663 -0
  37. tsugite/cli/cache.py +113 -0
  38. tsugite/cli/config.py +272 -0
  39. tsugite/cli/helpers.py +534 -0
  40. tsugite/cli/history.py +193 -0
  41. tsugite/cli/init.py +387 -0
  42. tsugite/cli/mcp.py +193 -0
  43. tsugite/cli/tools.py +419 -0
  44. tsugite/config.py +204 -0
  45. tsugite/console.py +48 -0
  46. tsugite/constants.py +21 -0
  47. tsugite/core/__init__.py +19 -0
  48. tsugite/core/agent.py +774 -0
  49. tsugite/core/executor.py +300 -0
  50. tsugite/core/memory.py +67 -0
  51. tsugite/core/tools.py +271 -0
  52. tsugite/docker_cli.py +270 -0
  53. tsugite/events/__init__.py +55 -0
  54. tsugite/events/base.py +46 -0
  55. tsugite/events/bus.py +62 -0
  56. tsugite/events/events.py +224 -0
  57. tsugite/exceptions.py +40 -0
  58. tsugite/history/__init__.py +29 -0
  59. tsugite/history/index.py +210 -0
  60. tsugite/history/models.py +106 -0
  61. tsugite/history/storage.py +157 -0
  62. tsugite/mcp_client.py +219 -0
  63. tsugite/mcp_config.py +174 -0
  64. tsugite/md_agents.py +751 -0
  65. tsugite/models.py +257 -0
  66. tsugite/renderer.py +151 -0
  67. tsugite/shell_tool_config.py +265 -0
  68. tsugite/templates/assistant.md +14 -0
  69. tsugite/tools/__init__.py +265 -0
  70. tsugite/tools/agents.py +312 -0
  71. tsugite/tools/edit_strategies.py +393 -0
  72. tsugite/tools/fs.py +329 -0
  73. tsugite/tools/http.py +239 -0
  74. tsugite/tools/interactive.py +430 -0
  75. tsugite/tools/shell.py +129 -0
  76. tsugite/tools/shell_tools.py +214 -0
  77. tsugite/tools/tasks.py +339 -0
  78. tsugite/tsugite.py +7 -0
  79. tsugite/ui/__init__.py +46 -0
  80. tsugite/ui/base.py +638 -0
  81. tsugite/ui/chat.py +265 -0
  82. tsugite/ui/chat.tcss +92 -0
  83. tsugite/ui/chat_history.py +286 -0
  84. tsugite/ui/helpers.py +102 -0
  85. tsugite/ui/jsonl.py +125 -0
  86. tsugite/ui/live_template.py +529 -0
  87. tsugite/ui/plain.py +419 -0
  88. tsugite/ui/textual_chat.py +642 -0
  89. tsugite/ui/textual_handler.py +225 -0
  90. tsugite/ui/widgets/__init__.py +6 -0
  91. tsugite/ui/widgets/base_scroll_log.py +27 -0
  92. tsugite/ui/widgets/message_list.py +121 -0
  93. tsugite/ui/widgets/thought_log.py +80 -0
  94. tsugite/ui_context.py +90 -0
  95. tsugite/utils.py +367 -0
  96. tsugite/xdg.py +104 -0
  97. tsugite_cli-0.3.3.dist-info/METADATA +325 -0
  98. tsugite_cli-0.3.3.dist-info/RECORD +101 -0
  99. tsugite_cli-0.3.3.dist-info/WHEEL +4 -0
  100. tsugite_cli-0.3.3.dist-info/entry_points.txt +5 -0
  101. tsugite_cli-0.3.3.dist-info/licenses/LICENSE +235 -0
tsugite/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Tsugite: Micro-agent runner for task automation."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ # Import submodules to make them accessible as attributes for tests
6
+ from tsugite import agent_runner # noqa: F401
@@ -0,0 +1,163 @@
1
+ """Agent composition utilities for multi-agent delegation."""
2
+
3
+ from pathlib import Path
4
+ from typing import List, Tuple
5
+
6
+ from tsugite.core.tools import Tool
7
+
8
+
9
+ def resolve_agent_reference(ref: str, base_dir: Path) -> Path:
10
+ """Resolve agent reference to file path.
11
+
12
+ Args:
13
+ ref: Agent reference (path or +name shorthand)
14
+ base_dir: Base directory for relative path resolution
15
+
16
+ Returns:
17
+ Resolved Path to agent file
18
+
19
+ Raises:
20
+ ValueError: If agent not found or invalid
21
+ """
22
+ # Handle +name shorthand
23
+ if ref.startswith("+"):
24
+ agent_name = ref[1:]
25
+ from .agent_inheritance import find_agent_file
26
+
27
+ found = find_agent_file(agent_name, base_dir)
28
+ if not found:
29
+ raise ValueError(f"Agent not found: {ref} (searched standard locations)")
30
+ return found
31
+
32
+ # Handle regular path
33
+ agent_path = Path(ref)
34
+ if not agent_path.is_absolute():
35
+ agent_path = base_dir / agent_path
36
+
37
+ if agent_path.exists():
38
+ return agent_path
39
+
40
+ # If path doesn't exist, try as agent name lookup
41
+ # This allows "helper" or "helper.md" to work like "+helper"
42
+ from .agent_inheritance import find_agent_file
43
+
44
+ found = find_agent_file(ref, base_dir)
45
+ if found:
46
+ return found
47
+
48
+ raise ValueError(f"Agent file not found: {ref}")
49
+
50
+
51
+ def create_delegation_tool(agent_name: str, agent_path: Path) -> Tool:
52
+ """Create a delegation tool for a specific agent.
53
+
54
+ Args:
55
+ agent_name: Name for the tool (e.g., "jira" -> spawn_jira)
56
+ agent_path: Path to the agent file
57
+
58
+ Returns:
59
+ Tool that wraps spawn_agent with pre-filled path
60
+ """
61
+
62
+ def delegation_function(prompt: str) -> str:
63
+ """Execute the delegated agent.
64
+
65
+ Args:
66
+ prompt: Task/prompt to give the agent
67
+
68
+ Returns:
69
+ str: Result from the delegated agent
70
+ """
71
+ from .agent_runner import run_agent
72
+
73
+ try:
74
+ result = run_agent(
75
+ agent_path=agent_path,
76
+ prompt=prompt,
77
+ context={},
78
+ model_override=None,
79
+ debug=False,
80
+ )
81
+ return str(result)
82
+ except Exception as e:
83
+ return f"Error executing {agent_name} agent: {e}"
84
+
85
+ # Create Tool instance with proper schema
86
+ return Tool(
87
+ name=f"spawn_{agent_name}",
88
+ description=f"Delegate a task to the {agent_name} agent and get the result.",
89
+ parameters={
90
+ "type": "object",
91
+ "properties": {"prompt": {"type": "string", "description": "Task/prompt to give the agent"}},
92
+ "required": ["prompt"],
93
+ },
94
+ function=delegation_function,
95
+ )
96
+
97
+
98
+ def create_delegation_tools(delegation_agents: List[Tuple[str, Path]]) -> List[Tool]:
99
+ """Create delegation tools for multiple agents.
100
+
101
+ Args:
102
+ delegation_agents: List of (agent_name, agent_path) tuples
103
+
104
+ Returns:
105
+ List of delegation tools
106
+ """
107
+ tools = []
108
+ for agent_name, agent_path in delegation_agents:
109
+ tool = create_delegation_tool(agent_name, agent_path)
110
+ tools.append(tool)
111
+ return tools
112
+
113
+
114
+ def parse_agent_references(
115
+ agent_refs: List[str], with_agents_str: str | None, base_dir: Path
116
+ ) -> Tuple[Path, List[Tuple[str, Path]]]:
117
+ """Parse agent references into primary agent and delegation agents.
118
+
119
+ Args:
120
+ agent_refs: List of agent references from CLI args
121
+ with_agents_str: Comma or space separated string from --with-agents option
122
+ base_dir: Base directory for resolution
123
+
124
+ Returns:
125
+ Tuple of (primary_agent_path, [(name, path), ...])
126
+
127
+ Raises:
128
+ ValueError: If no agents specified or resolution fails
129
+ """
130
+ if not agent_refs:
131
+ raise ValueError("No agent specified")
132
+
133
+ # Resolve primary agent (first in list)
134
+ primary_ref = agent_refs[0]
135
+ primary_path = resolve_agent_reference(primary_ref, base_dir)
136
+
137
+ # Collect delegation agent references
138
+ delegation_refs = []
139
+
140
+ # Add remaining positional agents
141
+ if len(agent_refs) > 1:
142
+ delegation_refs.extend(agent_refs[1:])
143
+
144
+ # Add --with-agents if provided
145
+ if with_agents_str:
146
+ # Support both comma and space separated
147
+ import re
148
+
149
+ refs = re.split(r"[,\s]+", with_agents_str.strip())
150
+ delegation_refs.extend([ref for ref in refs if ref])
151
+
152
+ # Resolve delegation agents
153
+ delegation_agents = []
154
+ for ref in delegation_refs:
155
+ agent_path = resolve_agent_reference(ref, base_dir)
156
+ # Extract name from reference (strip + if present, remove .md extension)
157
+ agent_name = ref.lstrip("+").removesuffix(".md")
158
+ # If it's a path, use the filename without extension
159
+ if "/" in agent_name:
160
+ agent_name = Path(agent_name).stem
161
+ delegation_agents.append((agent_name, agent_path))
162
+
163
+ return primary_path, delegation_agents
@@ -0,0 +1,479 @@
1
+ """Agent inheritance resolution for Tsugite."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+ from typing import Any, Dict, List, Optional, Set
6
+
7
+ from tsugite.utils import ensure_file_exists
8
+
9
+
10
+ def get_builtin_agents_path() -> Path:
11
+ """Get the built-in agents directory path.
12
+
13
+ Returns:
14
+ Path to built-in agents directory within the package
15
+ """
16
+ return Path(__file__).parent / "builtin_agents"
17
+
18
+
19
+ def get_global_agents_paths() -> List[Path]:
20
+ """Get global agent directory paths in precedence order.
21
+
22
+ Returns:
23
+ List of paths to check for global agents (XDG-compliant)
24
+ """
25
+ paths = []
26
+
27
+ # ~/.tsugite/agents/
28
+ home_tsugite = Path.home() / ".tsugite" / "agents"
29
+ paths.append(home_tsugite)
30
+
31
+ # $XDG_CONFIG_HOME/tsugite/agents/
32
+ xdg_config = os.environ.get("XDG_CONFIG_HOME")
33
+ if xdg_config:
34
+ xdg_path = Path(xdg_config) / "tsugite" / "agents"
35
+ paths.append(xdg_path)
36
+
37
+ # ~/.config/tsugite/agents/
38
+ default_path = Path.home() / ".config" / "tsugite" / "agents"
39
+ paths.append(default_path)
40
+
41
+ return paths
42
+
43
+
44
+ def find_agent_file(agent_ref: str, current_agent_dir: Path) -> Optional[Path]:
45
+ """Find an agent file by reference.
46
+
47
+ Args:
48
+ agent_ref: Agent reference (name or path)
49
+ current_agent_dir: Directory of the agent doing the extending
50
+
51
+ Returns:
52
+ Path to agent file if found, None otherwise
53
+
54
+ Search order:
55
+ 1. If path-like (contains / or .md), resolve relative to current agent
56
+ 2. .tsugite/{name}.md (project-local shared)
57
+ 3. ./agents/{name}.md (project convention)
58
+ 4. ./{name}.md (current directory)
59
+ 5. Built-in agents directory (tsugite/builtin_agents/)
60
+ 6. Global agent directories (XDG order)
61
+ """
62
+ # If it looks like a path, resolve it relative to current agent
63
+ if "/" in agent_ref or agent_ref.endswith(".md"):
64
+ path = current_agent_dir / agent_ref
65
+ if path.exists():
66
+ return path.resolve()
67
+ # Also try as absolute path
68
+ abs_path = Path(agent_ref).expanduser()
69
+ if abs_path.exists():
70
+ return abs_path.resolve()
71
+ return None
72
+
73
+ # Ensure .md extension for name-based lookup
74
+ agent_name = agent_ref if agent_ref.endswith(".md") else f"{agent_ref}.md"
75
+
76
+ # Search project-local locations
77
+ search_paths = [
78
+ current_agent_dir / ".tsugite" / agent_name,
79
+ current_agent_dir / "agents" / agent_name,
80
+ current_agent_dir / agent_name,
81
+ ]
82
+
83
+ # Add built-in agents directory
84
+ builtin_path = get_builtin_agents_path() / agent_name
85
+ search_paths.append(builtin_path)
86
+
87
+ # Add global locations
88
+ for global_dir in get_global_agents_paths():
89
+ search_paths.append(global_dir / agent_name)
90
+
91
+ # Return first existing path
92
+ for path in search_paths:
93
+ if path.exists():
94
+ return path.resolve()
95
+
96
+ return None
97
+
98
+
99
+ def detect_circular_inheritance(agent_path: Path, inheritance_chain: Set[Path]) -> bool:
100
+ """Detect circular inheritance.
101
+
102
+ Args:
103
+ agent_path: Path of agent being loaded
104
+ inheritance_chain: Set of agent paths already in the chain
105
+
106
+ Returns:
107
+ True if circular inheritance detected, False otherwise
108
+ """
109
+ resolved_path = agent_path.resolve()
110
+ return resolved_path in inheritance_chain
111
+
112
+
113
+ def load_extended_agent(extends_ref: str, current_agent_path: Path, inheritance_chain: Set[Path]):
114
+ """Load an extended (parent) agent.
115
+
116
+ Args:
117
+ extends_ref: Reference to parent agent
118
+ current_agent_path: Path to current agent file
119
+ inheritance_chain: Set of agent paths in current inheritance chain
120
+
121
+ Returns:
122
+ Parsed Agent object
123
+
124
+ Raises:
125
+ ValueError: If agent not found or circular inheritance detected
126
+ """
127
+ from .md_agents import parse_agent
128
+
129
+ current_agent_dir = current_agent_path.parent if current_agent_path.is_file() else current_agent_path
130
+
131
+ agent_path = find_agent_file(extends_ref, current_agent_dir)
132
+
133
+ if agent_path is None:
134
+ raise ValueError(f"Extended agent not found: {extends_ref}")
135
+
136
+ # Check for circular inheritance
137
+ if detect_circular_inheritance(agent_path, inheritance_chain):
138
+ chain_str = " -> ".join(str(p) for p in inheritance_chain)
139
+ raise ValueError(f"Circular inheritance detected: {chain_str} -> {agent_path}")
140
+
141
+ # Load the parent agent WITHOUT resolving its inheritance yet
142
+ # (we'll do that recursively in resolve_agent_inheritance)
143
+ ensure_file_exists(agent_path, "Agent file")
144
+
145
+ content = agent_path.read_text(encoding="utf-8")
146
+ return parse_agent(content, agent_path)
147
+
148
+
149
+ def merge_agent_configs(parent, child):
150
+ """Merge parent and child agent configurations.
151
+
152
+ Args:
153
+ parent: Parent AgentConfig
154
+ child: Child AgentConfig
155
+
156
+ Returns:
157
+ Merged AgentConfig with child taking precedence
158
+
159
+ Merge rules:
160
+ - Scalars (model, max_turns, reasoning_effort, text_mode, etc.): child overwrites parent
161
+ - Lists (tools, attachments): merge and deduplicate
162
+ - Lists (prefetch): concatenate (parent first, no deduplication)
163
+ - Lists of dicts (custom_tools): merge and deduplicate by "name" field
164
+ - Dicts (mcp_servers, context_budget): merge, child keys override
165
+ - Strings (instructions): concatenate with newline
166
+ """
167
+ from .md_agents import AgentConfig
168
+
169
+ # Merge all field types
170
+ merged_data = {}
171
+ merged_data.update(merge_scalar_fields(parent, child))
172
+ merged_data.update(merge_list_fields(parent, child))
173
+ merged_data.update(merge_dict_fields(parent, child))
174
+ merged_data["instructions"] = merge_instructions(parent, child)
175
+
176
+ return AgentConfig(**merged_data)
177
+
178
+
179
+ def resolve_agent_inheritance(agent, agent_path: Path, inheritance_chain: Optional[Set[Path]] = None):
180
+ """Resolve agent inheritance chain.
181
+
182
+ Args:
183
+ agent: Parsed Agent object
184
+ agent_path: Path to agent file
185
+ inheritance_chain: Set of agent paths already processed (for cycle detection)
186
+
187
+ Returns:
188
+ Agent object with resolved inheritance
189
+
190
+ Inheritance chain:
191
+ 1. Default base agent (from config, usually "default")
192
+ 2. Extended agent (if extends specified)
193
+ 3. Current agent
194
+
195
+ Raises:
196
+ ValueError: If circular inheritance or missing agent
197
+ """
198
+ from .md_agents import Agent
199
+
200
+ if inheritance_chain is None:
201
+ inheritance_chain = set()
202
+
203
+ # Add current agent to chain
204
+ resolved_path = agent_path.resolve()
205
+ inheritance_chain.add(resolved_path)
206
+
207
+ # Check if explicitly opted out
208
+ if agent.config.extends == "none":
209
+ return agent
210
+
211
+ # Build inheritance chain
212
+ configs_to_merge = []
213
+ contents_to_merge = []
214
+
215
+ # 1. Load default base agent (only for implicit inheritance)
216
+ if agent.config.extends is None:
217
+ default_config, default_content = load_default_base_agent(agent_path, resolved_path, inheritance_chain)
218
+ if default_config:
219
+ configs_to_merge.append(default_config)
220
+ contents_to_merge.append(default_content)
221
+
222
+ # 2. Load explicitly extended agent
223
+ if agent.config.extends and agent.config.extends != "none":
224
+ parent_config, parent_content = load_explicit_parent_agent(agent.config.extends, agent_path, inheritance_chain)
225
+ configs_to_merge.append(parent_config)
226
+ contents_to_merge.append(parent_content)
227
+
228
+ # 3. Current agent is last (highest precedence)
229
+ configs_to_merge.append(agent.config)
230
+ contents_to_merge.append(agent.content)
231
+
232
+ # Merge all configs and content
233
+ if len(configs_to_merge) == 1:
234
+ return agent
235
+
236
+ merged_config, merged_content = merge_configs_and_content(configs_to_merge, contents_to_merge)
237
+ return Agent(config=merged_config, content=merged_content, file_path=agent_path)
238
+
239
+
240
+ def _get_default_base_agent_name() -> Optional[str]:
241
+ """Get the default base agent name from config.
242
+
243
+ Returns:
244
+ Default base agent name, or "default" as fallback, or None if disabled
245
+ """
246
+ from .config import load_config
247
+
248
+ try:
249
+ config = load_config()
250
+ if config.default_base_agent is None:
251
+ return "default"
252
+ return config.default_base_agent
253
+ except Exception:
254
+ return "default"
255
+
256
+
257
+ def merge_scalar_fields(parent, child) -> Dict[str, Any]:
258
+ """Merge scalar fields from parent and child configs.
259
+
260
+ Child values take precedence when explicitly set.
261
+
262
+ Args:
263
+ parent: Parent AgentConfig
264
+ child: Child AgentConfig
265
+
266
+ Returns:
267
+ Dict of merged scalar fields
268
+ """
269
+ return {
270
+ "name": child.name if child.name else parent.name,
271
+ "description": child.description if child.description else parent.description,
272
+ "model": child.model if child.model else parent.model,
273
+ "max_turns": child.max_turns if child.max_turns != 5 else parent.max_turns,
274
+ "permissions_profile": (
275
+ child.permissions_profile if child.permissions_profile != "default" else parent.permissions_profile
276
+ ),
277
+ "reasoning_effort": child.reasoning_effort if child.reasoning_effort else parent.reasoning_effort,
278
+ "text_mode": child.text_mode if child.text_mode else parent.text_mode,
279
+ }
280
+
281
+
282
+ def merge_list_fields(parent, child) -> Dict[str, List]:
283
+ """Merge list fields from parent and child configs.
284
+
285
+ Different list types have different merge strategies:
286
+ - tools, attachments: merge and deduplicate
287
+ - prefetch, initial_tasks: concatenate (parent first)
288
+ - custom_tools: deduplicate by "name" field
289
+
290
+ Args:
291
+ parent: Parent AgentConfig
292
+ child: Child AgentConfig
293
+
294
+ Returns:
295
+ Dict of merged list fields
296
+ """
297
+ # Simple lists - merge and deduplicate while preserving order
298
+ parent_tools = parent.tools if parent.tools else []
299
+ child_tools = child.tools if child.tools else []
300
+ merged_tools = list(dict.fromkeys(parent_tools + child_tools))
301
+
302
+ parent_attachments = parent.attachments if parent.attachments else []
303
+ child_attachments = child.attachments if child.attachments else []
304
+ merged_attachments = list(dict.fromkeys(parent_attachments + child_attachments))
305
+
306
+ # Lists that concatenate without deduplication
307
+ parent_prefetch = parent.prefetch if parent.prefetch else []
308
+ child_prefetch = child.prefetch if child.prefetch else []
309
+
310
+ parent_initial_tasks = parent.initial_tasks if parent.initial_tasks else []
311
+ child_initial_tasks = child.initial_tasks if child.initial_tasks else []
312
+
313
+ # Custom tools - deduplicate by "name" field (child overrides parent)
314
+ parent_custom = parent.custom_tools if parent.custom_tools else []
315
+ child_custom = child.custom_tools if child.custom_tools else []
316
+ custom_tool_dict = {}
317
+ for tool in parent_custom + child_custom:
318
+ custom_tool_dict[tool["name"]] = tool
319
+
320
+ return {
321
+ "tools": merged_tools,
322
+ "attachments": merged_attachments,
323
+ "prefetch": parent_prefetch + child_prefetch,
324
+ "initial_tasks": parent_initial_tasks + child_initial_tasks,
325
+ "custom_tools": list(custom_tool_dict.values()),
326
+ }
327
+
328
+
329
+ def merge_dict_fields(parent, child) -> Dict[str, Any]:
330
+ """Merge dictionary fields from parent and child configs.
331
+
332
+ Child keys override parent keys.
333
+
334
+ Args:
335
+ parent: Parent AgentConfig
336
+ child: Child AgentConfig
337
+
338
+ Returns:
339
+ Dict of merged dict fields
340
+ """
341
+ # MCP servers
342
+ parent_mcp = parent.mcp_servers if parent.mcp_servers else {}
343
+ child_mcp = child.mcp_servers if child.mcp_servers else {}
344
+ merged_mcp = {**parent_mcp, **child_mcp}
345
+
346
+ # Context budget
347
+ parent_context = parent.context_budget if parent.context_budget else {}
348
+ child_context = child.context_budget if child.context_budget else {}
349
+ merged_context = {**parent_context, **child_context}
350
+
351
+ return {
352
+ "mcp_servers": merged_mcp,
353
+ "context_budget": merged_context,
354
+ }
355
+
356
+
357
+ def merge_instructions(parent, child) -> str:
358
+ """Merge instructions from parent and child configs.
359
+
360
+ Concatenates with double newline separator.
361
+
362
+ Args:
363
+ parent: Parent AgentConfig
364
+ child: Child AgentConfig
365
+
366
+ Returns:
367
+ Merged instructions string
368
+ """
369
+ parent_instructions = parent.instructions if parent.instructions else ""
370
+ child_instructions = child.instructions if child.instructions else ""
371
+
372
+ if parent_instructions and child_instructions:
373
+ return f"{parent_instructions}\n\n{child_instructions}"
374
+ elif parent_instructions:
375
+ return parent_instructions
376
+ elif child_instructions:
377
+ return child_instructions
378
+ else:
379
+ return ""
380
+
381
+
382
+ def load_default_base_agent(
383
+ agent_path: Path, resolved_path: Path, inheritance_chain: Set[Path]
384
+ ) -> tuple[Optional[Any], Optional[str]]:
385
+ """Load default base agent for implicit inheritance.
386
+
387
+ Search priority:
388
+ 1. User's project-local default.md (if exists)
389
+ 2. Config's default_base_agent
390
+ 3. Builtin fallback
391
+
392
+ Args:
393
+ agent_path: Path to current agent file
394
+ resolved_path: Resolved path to current agent
395
+ inheritance_chain: Set of agent paths already in chain
396
+
397
+ Returns:
398
+ Tuple of (loaded_agent, loaded_content) or (None, None) if not loaded
399
+ """
400
+ # Try project-local default.md first
401
+ user_default_path = agent_path.parent / ".tsugite" / "default.md"
402
+
403
+ if user_default_path.exists() and user_default_path.resolve() != resolved_path:
404
+ if user_default_path.resolve() not in inheritance_chain:
405
+ try:
406
+ default_agent = load_extended_agent("default", agent_path, inheritance_chain.copy())
407
+ # User's default.md should opt out of builtin inheritance unless explicit
408
+ if default_agent.config.extends is None:
409
+ # Standalone user default
410
+ return default_agent.config, default_agent.content
411
+ else:
412
+ # User explicitly extended something, resolve it
413
+ default_agent = resolve_agent_inheritance(
414
+ default_agent, user_default_path, inheritance_chain.copy()
415
+ )
416
+ return default_agent.config, default_agent.content
417
+ except ValueError:
418
+ pass
419
+
420
+ # Fall back to config or builtin
421
+ default_base_name = _get_default_base_agent_name()
422
+ if not default_base_name:
423
+ return None, None
424
+
425
+ # Try to find the configured default agent (including built-ins via find_agent_file)
426
+ default_path = find_agent_file(default_base_name, agent_path.parent)
427
+ if default_path and default_path.resolve() != resolved_path:
428
+ if default_path.resolve() not in inheritance_chain:
429
+ try:
430
+ default_agent = load_extended_agent(default_base_name, agent_path, inheritance_chain.copy())
431
+ default_agent = resolve_agent_inheritance(default_agent, default_path, inheritance_chain.copy())
432
+ return default_agent.config, default_agent.content
433
+ except ValueError:
434
+ pass
435
+
436
+ return None, None
437
+
438
+
439
+ def load_explicit_parent_agent(extends_ref: str, agent_path: Path, inheritance_chain: Set[Path]) -> tuple[Any, str]:
440
+ """Load explicitly extended parent agent.
441
+
442
+ Args:
443
+ extends_ref: Reference to parent agent
444
+ agent_path: Path to current agent file
445
+ inheritance_chain: Set of agent paths already in chain
446
+
447
+ Returns:
448
+ Tuple of (parent_config, parent_content)
449
+ """
450
+ parent_agent = load_extended_agent(extends_ref, agent_path, inheritance_chain.copy())
451
+ parent_path = parent_agent.file_path
452
+
453
+ # Recursively resolve parent's inheritance
454
+ parent_agent = resolve_agent_inheritance(parent_agent, parent_path, inheritance_chain.copy())
455
+ return parent_agent.config, parent_agent.content
456
+
457
+
458
+ def merge_configs_and_content(configs: List, contents: List):
459
+ """Merge a list of configs and contents.
460
+
461
+ Args:
462
+ configs: List of AgentConfig objects (earlier = lower precedence)
463
+ contents: List of content strings
464
+
465
+ Returns:
466
+ Tuple of (merged_config, merged_content)
467
+ """
468
+ if len(configs) == 1:
469
+ return configs[0], contents[0]
470
+
471
+ # Merge configs in order
472
+ merged_config = configs[0]
473
+ for config in configs[1:]:
474
+ merged_config = merge_agent_configs(merged_config, config)
475
+
476
+ # Merge contents
477
+ merged_content = "\n\n".join(contents)
478
+
479
+ return merged_config, merged_content