quick-agent 0.1.1__py3-none-any.whl → 0.1.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 (35) hide show
  1. quick_agent/__init__.py +4 -1
  2. quick_agent/agent_call_tool.py +22 -5
  3. quick_agent/agent_registry.py +7 -27
  4. quick_agent/agent_tools.py +3 -2
  5. quick_agent/cli.py +19 -5
  6. quick_agent/directory_permissions.py +7 -3
  7. quick_agent/input_adaptors.py +30 -0
  8. quick_agent/llms.txt +239 -0
  9. quick_agent/models/agent_spec.py +3 -0
  10. quick_agent/models/loaded_agent_file.py +136 -1
  11. quick_agent/models/output_spec.py +1 -1
  12. quick_agent/orchestrator.py +15 -8
  13. quick_agent/prompting.py +34 -16
  14. quick_agent/py.typed +1 -0
  15. quick_agent/quick_agent.py +171 -155
  16. quick_agent/schemas/outputs.py +6 -0
  17. quick_agent-0.1.3.data/data/quick_agent/agents/business-extract-structured.md +49 -0
  18. quick_agent-0.1.3.data/data/quick_agent/agents/business-extract.md +42 -0
  19. {quick_agent-0.1.1.data → quick_agent-0.1.3.data}/data/quick_agent/agents/function-spec-validator.md +1 -1
  20. {quick_agent-0.1.1.data → quick_agent-0.1.3.data}/data/quick_agent/agents/subagent-validate-eval-list.md +1 -1
  21. {quick_agent-0.1.1.data → quick_agent-0.1.3.data}/data/quick_agent/agents/subagent-validator-contains.md +8 -1
  22. {quick_agent-0.1.1.data → quick_agent-0.1.3.data}/data/quick_agent/agents/template.md +12 -1
  23. {quick_agent-0.1.1.dist-info → quick_agent-0.1.3.dist-info}/METADATA +21 -4
  24. quick_agent-0.1.3.dist-info/RECORD +52 -0
  25. tests/test_agent.py +273 -9
  26. tests/test_directory_permissions.py +10 -0
  27. tests/test_httpx_tools.py +295 -0
  28. tests/test_input_adaptors.py +31 -0
  29. tests/test_integration.py +134 -1
  30. tests/test_orchestrator.py +525 -111
  31. quick_agent-0.1.1.dist-info/RECORD +0 -45
  32. {quick_agent-0.1.1.dist-info → quick_agent-0.1.3.dist-info}/WHEEL +0 -0
  33. {quick_agent-0.1.1.dist-info → quick_agent-0.1.3.dist-info}/entry_points.txt +0 -0
  34. {quick_agent-0.1.1.dist-info → quick_agent-0.1.3.dist-info}/licenses/LICENSE +0 -0
  35. {quick_agent-0.1.1.dist-info → quick_agent-0.1.3.dist-info}/top_level.txt +0 -0
quick_agent/__init__.py CHANGED
@@ -1,6 +1,9 @@
1
1
  """Public package exports."""
2
2
 
3
+ from quick_agent.input_adaptors import FileInput
4
+ from quick_agent.input_adaptors import InputAdaptor
5
+ from quick_agent.input_adaptors import TextInput
3
6
  from quick_agent.orchestrator import Orchestrator
4
7
  from quick_agent.quick_agent import QuickAgent
5
8
 
6
- __all__ = ["Orchestrator", "QuickAgent"]
9
+ __all__ = ["FileInput", "InputAdaptor", "Orchestrator", "QuickAgent", "TextInput"]
@@ -7,11 +7,13 @@ from typing import Any, Awaitable, Callable
7
7
 
8
8
  from pydantic import BaseModel
9
9
 
10
+ from quick_agent.input_adaptors import InputAdaptor, TextInput
11
+
10
12
 
11
13
  class AgentCallTool:
12
14
  def __init__(
13
15
  self,
14
- call_agent: Callable[[str, Path], Awaitable[BaseModel | str]],
16
+ call_agent: Callable[[str, InputAdaptor | Path], Awaitable[BaseModel | str]],
15
17
  run_input_source_path: str,
16
18
  ) -> None:
17
19
  self._call_agent = call_agent
@@ -32,13 +34,28 @@ class AgentCallTool:
32
34
  path = base_dir / path
33
35
  return path
34
36
 
35
- async def __call__(self, agent: str, input_file: str) -> dict[str, Any]:
37
+ async def __call__(
38
+ self,
39
+ agent: str,
40
+ input_file: str | None = None,
41
+ input_text: str | None = None,
42
+ ) -> dict[str, Any]:
36
43
  """
37
- Call another agent by ID with an input file path.
44
+ Call another agent by ID with an input file path or inline text.
38
45
  Returns JSON-serializable dict output if structured, else {"text": "..."}.
39
46
  """
40
- resolved_input = self._resolve_input_file(input_file)
41
- out = await self._call_agent(agent, resolved_input)
47
+ if input_file and input_text:
48
+ raise ValueError("Provide only one of input_file or input_text.")
49
+ if not input_file and input_text is None:
50
+ raise ValueError("Provide either input_file or input_text.")
51
+ if input_text is not None:
52
+ input_data: InputAdaptor | Path = TextInput(input_text)
53
+ else:
54
+ if input_file is None:
55
+ raise ValueError("Provide either input_file or input_text.")
56
+ resolved_input = self._resolve_input_file(input_file)
57
+ input_data = resolved_input
58
+ out = await self._call_agent(agent, input_data)
42
59
  if isinstance(out, BaseModel):
43
60
  return out.model_dump()
44
61
  return {"text": out}
@@ -2,43 +2,23 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import re
6
5
  from pathlib import Path
7
6
 
8
- import frontmatter
9
-
10
- from quick_agent.models.agent_spec import AgentSpec
11
- from quick_agent.models.loaded_agent_file import LoadedAgentFile
7
+ from quick_agent.models.loaded_agent_file import LoadedAgentFile, parse_agent_sections
12
8
 
13
9
 
14
10
  def split_step_sections(markdown_body: str) -> dict[str, str]:
15
11
  """
16
- Extracts blocks that begin with headings "## step:<id>".
12
+ Extracts blocks that begin with headings like "# step:<id>".
17
13
  Returns mapping: "step:<id>" -> content for that step (excluding heading line).
18
14
  """
19
- pattern = re.compile(r"^##\s+(step:[A-Za-z0-9_\-]+)\s*$", re.MULTILINE)
20
- matches = list(pattern.finditer(markdown_body))
21
- out: dict[str, str] = {}
22
-
23
- for i, m in enumerate(matches):
24
- section_name = m.group(1)
25
- start = m.end()
26
- end = matches[i + 1].start() if (i + 1) < len(matches) else len(markdown_body)
27
- out[section_name] = markdown_body[start:end].strip()
28
-
29
- return out
30
-
31
-
32
- def load_agent_file(path: Path) -> LoadedAgentFile:
33
- post = frontmatter.load(str(path))
34
- spec = AgentSpec.model_validate(post.metadata)
35
- steps = split_step_sections(post.content)
36
- return LoadedAgentFile(spec=spec, body=post.content, step_prompts=steps)
15
+ sections = parse_agent_sections(markdown_body)
16
+ return sections.step_prompts
37
17
 
38
18
 
39
19
  class AgentRegistry:
40
- def __init__(self, agent_roots: list[Path]):
41
- self.agent_roots = agent_roots
20
+ def __init__(self, agent_roots: list[Path]) -> None:
21
+ self.agent_roots: list[Path] = agent_roots
42
22
  self._cache: dict[str, LoadedAgentFile] = {}
43
23
  self._index: dict[str, Path] | None = None
44
24
 
@@ -70,6 +50,6 @@ class AgentRegistry:
70
50
  path = index.get(agent_id)
71
51
  if path is None:
72
52
  raise FileNotFoundError(f"Agent not found: {agent_id} (searched: {self.agent_roots})")
73
- loaded = load_agent_file(path)
53
+ loaded = LoadedAgentFile(path)
74
54
  self._cache[agent_id] = loaded
75
55
  return loaded
@@ -10,12 +10,13 @@ from pydantic_ai.toolsets import FunctionToolset
10
10
 
11
11
  from quick_agent.agent_call_tool import AgentCallTool
12
12
  from quick_agent.directory_permissions import DirectoryPermissions
13
+ from quick_agent.input_adaptors import InputAdaptor
13
14
  from quick_agent.tools_loader import load_tools
14
15
 
15
16
 
16
17
  class AgentTools:
17
18
  def __init__(self, tool_roots: list[Path]) -> None:
18
- self._tool_roots = tool_roots
19
+ self._tool_roots: list[Path] = tool_roots
19
20
 
20
21
  def build_toolset(self, tool_ids: list[str], permissions: DirectoryPermissions) -> FunctionToolset[Any]:
21
22
  tool_ids_for_disk = [tool_id for tool_id in tool_ids if tool_id != "agent.call"]
@@ -28,7 +29,7 @@ class AgentTools:
28
29
  tool_ids: list[str],
29
30
  toolset: FunctionToolset[Any],
30
31
  run_input_source_path: str,
31
- call_agent: Callable[[str, Path], Awaitable[BaseModel | str]],
32
+ call_agent: Callable[[str, InputAdaptor | Path], Awaitable[BaseModel | str]],
32
33
  ) -> None:
33
34
  if "agent.call" not in tool_ids:
34
35
  return
quick_agent/cli.py CHANGED
@@ -7,16 +7,28 @@ from pathlib import Path
7
7
 
8
8
  from pydantic import BaseModel
9
9
 
10
+ from quick_agent.input_adaptors import InputAdaptor, TextInput
10
11
  from quick_agent.orchestrator import Orchestrator
11
12
 
12
13
 
14
+ async def run_agent(
15
+ orch: Orchestrator,
16
+ agent_id: str,
17
+ input_adaptor: InputAdaptor | Path,
18
+ extra_tools: list[str],
19
+ ) -> BaseModel | str:
20
+ return await orch.run(agent_id, input_adaptor, extra_tools=extra_tools)
21
+
22
+
13
23
  def main() -> None:
14
24
  parser = argparse.ArgumentParser()
15
25
  parser.add_argument("--agents-dir", type=str, default="agents")
16
26
  parser.add_argument("--tools-dir", type=str, default="tools")
17
27
  parser.add_argument("--safe-dir", type=str, default="safe")
18
28
  parser.add_argument("--agent", type=str, required=True)
19
- parser.add_argument("--input", type=str, required=True)
29
+ input_group = parser.add_mutually_exclusive_group(required=True)
30
+ input_group.add_argument("--input", type=str, help="Path to an input file")
31
+ input_group.add_argument("--input-text", type=str, help="Raw input text")
20
32
  parser.add_argument("--tool", action="append", default=[], help="Extra tool IDs to add at runtime")
21
33
  args = parser.parse_args()
22
34
 
@@ -30,14 +42,16 @@ def main() -> None:
30
42
  tool_roots = [user_tools_dir, system_tools_dir]
31
43
 
32
44
  orch = Orchestrator(agent_roots, tool_roots, Path(args.safe_dir))
45
+ input_adaptor: InputAdaptor | Path
46
+ if args.input_text is not None:
47
+ input_adaptor = TextInput(args.input_text)
48
+ else:
49
+ input_adaptor = Path(args.input)
33
50
 
34
51
  # Async entrypoint
35
52
  import anyio
36
53
 
37
- async def runner():
38
- return await orch.run(args.agent, Path(args.input), extra_tools=args.tool)
39
-
40
- out = anyio.run(runner)
54
+ out = anyio.run(run_agent, orch, args.agent, input_adaptor, args.tool)
41
55
  if isinstance(out, BaseModel):
42
56
  print(out.model_dump_json(indent=2))
43
57
  else:
@@ -6,14 +6,16 @@ from pathlib import Path
6
6
 
7
7
 
8
8
  class DirectoryPermissions:
9
- def __init__(self, root: Path) -> None:
10
- self._root = root.expanduser().resolve(strict=False)
9
+ def __init__(self, root: Path | None) -> None:
10
+ self._root = root.expanduser().resolve(strict=False) if root is not None else None
11
11
 
12
12
  @property
13
- def root(self) -> Path:
13
+ def root(self) -> Path | None:
14
14
  return self._root
15
15
 
16
16
  def scoped(self, directory: str | None) -> "DirectoryPermissions":
17
+ if self._root is None:
18
+ return self
17
19
  if directory:
18
20
  candidate = (self._root / directory).expanduser().resolve(strict=False)
19
21
  root_resolved = self._root.expanduser().resolve(strict=False)
@@ -23,6 +25,8 @@ class DirectoryPermissions:
23
25
  return self
24
26
 
25
27
  def resolve(self, path: Path, *, for_write: bool) -> Path:
28
+ if self._root is None:
29
+ raise PermissionError("No safe directory configured; reads and writes are denied.")
26
30
  target = path
27
31
  if not target.is_absolute():
28
32
  target = self._root / target
@@ -0,0 +1,30 @@
1
+ """Input adaptors for agents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from quick_agent.directory_permissions import DirectoryPermissions
8
+ from quick_agent.io_utils import load_input
9
+ from quick_agent.models.run_input import RunInput
10
+
11
+
12
+ class InputAdaptor:
13
+ def load(self) -> RunInput:
14
+ raise NotImplementedError("InputAdaptor.load must be implemented by subclasses.")
15
+
16
+
17
+ class FileInput(InputAdaptor):
18
+ def __init__(self, path: Path, permissions: DirectoryPermissions) -> None:
19
+ self._run_input = load_input(path, permissions)
20
+
21
+ def load(self) -> RunInput:
22
+ return self._run_input
23
+
24
+
25
+ class TextInput(InputAdaptor):
26
+ def __init__(self, text: str) -> None:
27
+ self._text = text
28
+
29
+ def load(self) -> RunInput:
30
+ return RunInput(source_path="inline_input.txt", kind="text", text=self._text, data=None)
quick_agent/llms.txt ADDED
@@ -0,0 +1,239 @@
1
+ # Quick Agent (llms.txt)
2
+
3
+ **Project Summary**
4
+ - Quick Agent is a minimal, local-first agent runner that loads agent definitions from Markdown front matter and executes a small chain of steps with bounded context.
5
+ - Agents are Markdown files with YAML front matter for model/tools/chain and `## step:<id>` sections for prompts.
6
+ - Execution is deterministic: steps run in order, state stores prior step outputs, and optional handoff can invoke another agent. If `output.file` is set, the final output is written to disk.
7
+
8
+ **Primary Entry Points**
9
+ - CLI: `quick-agent --agent <id> --input <path>` (module: `quick_agent.cli:main`).
10
+ - Python API: `quick_agent.orchestrator.Orchestrator` and `quick_agent.quick_agent.QuickAgent`.
11
+
12
+ **How It Works (High Level)**
13
+ - Agent files are loaded by `AgentRegistry` from user and packaged directories.
14
+ - `QuickAgent` loads the agent spec, scopes file permissions, builds the toolset, then runs each chain step (text or structured).
15
+ - Structured steps validate JSON output against a Pydantic schema and attempt extraction if the raw output contains extra text.
16
+ - Outputs are written via `io_utils.write_output` and constrained by `DirectoryPermissions`.
17
+ - Input handling uses adaptors: file inputs are permission-checked at creation, and text inputs bypass filesystem access.
18
+
19
+ **Key Files**
20
+ - `src/quick_agent/quick_agent.py`: core execution engine and step handling.
21
+ - `src/quick_agent/orchestrator.py`: convenience wrapper for running agents.
22
+ - `src/quick_agent/agent_registry.py`: loads agent Markdown files.
23
+ - `src/quick_agent/agent_tools.py`: loads tools and builds the toolset.
24
+ - `src/quick_agent/directory_permissions.py`: enforces safe directory access.
25
+ - `src/quick_agent/tools/**/tool.json`: tool definitions (id, module, function).
26
+ - `agents/`: example agents shipped with the repo.
27
+ - `docs/`: CLI, template, and Python usage documentation.
28
+
29
+ **Agent File Format (Essentials)**
30
+ - Required fields: `name`, `model.base_url`, `model.model_name`, `chain`.
31
+ - Each `chain` step must reference a matching `## step:<id>` section in the body.
32
+ - Tools are referenced by id and resolved from `tool.json` definitions.
33
+ - `safe_dir` must be relative and further scopes file access inside the CLI safe root.
34
+
35
+ **Safety and Permissions**
36
+ - File reads and writes are restricted to a safe directory configured by `--safe-dir` (default `safe`).
37
+ - Agent-level `safe_dir` can further restrict access to a subdirectory.
38
+ - If no safe directory is configured, all reads and writes are denied.
39
+
40
+ **Tests**
41
+ - Tests live in `src/tests`.
42
+ - Run with `pytest` (requires optional dependency group `test`).
43
+
44
+ **Useful Docs**
45
+ - `docs/cli.md`: CLI usage and options.
46
+ - `docs/templates.md`: agent template format and examples.
47
+ - `docs/python.md`: embedding the orchestrator and inter-agent calls.
48
+ - `docs/state.md`: how chain state is stored and used.
49
+
50
+ **Mini Example Agent**
51
+ ```markdown
52
+ ---
53
+ name: "Hello Agent"
54
+ description: "Minimal example"
55
+ model:
56
+ provider: "openai-compatible"
57
+ base_url: "http://localhost:11434/v1"
58
+ api_key_env: "OPENAI_API_KEY"
59
+ model_name: "llama3"
60
+ chain:
61
+ - id: hello
62
+ kind: text
63
+ prompt_section: step:hello
64
+ output:
65
+ format: json
66
+ file: out/hello.json
67
+ ---
68
+
69
+ ## step:hello
70
+
71
+ Say hello to the input.
72
+ ```
73
+
74
+ **Mini Example (Structured Output)**
75
+ ```markdown
76
+ ---
77
+ name: "Structured Agent"
78
+ model:
79
+ provider: "openai-compatible"
80
+ base_url: "http://localhost:11434/v1"
81
+ api_key_env: "OPENAI_API_KEY"
82
+ model_name: "llama3"
83
+ schemas:
84
+ Summary: "quick_agent.schemas.outputs:SummaryOutput"
85
+ chain:
86
+ - id: summarize
87
+ kind: structured
88
+ prompt_section: step:summarize
89
+ output_schema: Summary
90
+ output:
91
+ format: json
92
+ file: out/summary.json
93
+ ---
94
+
95
+ ## step:summarize
96
+
97
+ Summarize the input into a short title and 2 bullet points.
98
+ ```
99
+
100
+ **Schema Snippet (for Structured Output)**
101
+ ```python
102
+ from pydantic import BaseModel
103
+
104
+
105
+ class SummaryOutput(BaseModel):
106
+ title: str
107
+ bullets: list[str]
108
+ ```
109
+
110
+ **Input Adaptors (Python)**
111
+ ```python
112
+ from pathlib import Path
113
+
114
+ from quick_agent import FileInput
115
+ from quick_agent import Orchestrator
116
+ from quick_agent import TextInput
117
+
118
+ orchestrator = Orchestrator([Path("agents")], [Path("tools")], safe_dir=Path("safe"))
119
+
120
+ file_input = FileInput(Path("safe/input.txt"), orchestrator.directory_permissions)
121
+ text_input = TextInput("hello from memory")
122
+
123
+ result_from_file = await orchestrator.run("example", file_input)
124
+ result_from_text = await orchestrator.run("example", text_input)
125
+ ```
126
+
127
+ **Multi-Step Example (State + Structured Output)**
128
+ ```markdown
129
+ ---
130
+ name: "Draft And Summarize"
131
+ model:
132
+ provider: "openai-compatible"
133
+ base_url: "http://localhost:11434/v1"
134
+ api_key_env: "OPENAI_API_KEY"
135
+ model_name: "llama3"
136
+ schemas:
137
+ Summary: "quick_agent.schemas.outputs:SummaryOutput"
138
+ chain:
139
+ - id: draft
140
+ kind: text
141
+ prompt_section: step:draft
142
+ - id: summarize
143
+ kind: structured
144
+ prompt_section: step:summarize
145
+ output_schema: Summary
146
+ output:
147
+ format: json
148
+ file: out/draft_summary.json
149
+ ---
150
+
151
+ ## step:draft
152
+
153
+ Write a short draft based on the input.
154
+
155
+ ## step:summarize
156
+
157
+ Summarize the draft from state as a title and 2 bullets.
158
+ Use the value at state.steps.draft.
159
+ ```
160
+
161
+ **Inter-Agent Call Example (agent.call)**
162
+ ```markdown
163
+ ---
164
+ name: "Parent Agent"
165
+ tools:
166
+ - "agent.call"
167
+ nested_output: inline
168
+ chain:
169
+ - id: invoke_child
170
+ kind: text
171
+ prompt_section: step:invoke_child
172
+ output:
173
+ format: json
174
+ file: out/parent.json
175
+ ---
176
+
177
+ ## step:invoke_child
178
+
179
+ Call agent_call with agent "child" and input_file "{base_directory}/child_input.txt".
180
+ Then respond with only the returned text value.
181
+ ```
182
+
183
+ ```markdown
184
+ ---
185
+ name: "Child Agent"
186
+ chain:
187
+ - id: respond
188
+ kind: text
189
+ prompt_section: step:respond
190
+ output:
191
+ format: json
192
+ file: out/child.json
193
+ ---
194
+
195
+ ## step:respond
196
+
197
+ Reply with exactly: pong
198
+ ```
199
+
200
+ Nested calls default to `nested_output: inline`, so the child agent above will not write
201
+ `out/child.json` unless the parent sets `nested_output: file`.
202
+
203
+ **Handoff Example (Run Another Agent After Output)**
204
+ ```markdown
205
+ ---
206
+ name: "Writer With Handoff"
207
+ chain:
208
+ - id: write
209
+ kind: text
210
+ prompt_section: step:write
211
+ output:
212
+ format: json
213
+ file: out/writer.json
214
+ handoff:
215
+ enabled: true
216
+ agent_id: "reviewer"
217
+ ---
218
+
219
+ ## step:write
220
+
221
+ Write a short answer to the input.
222
+ ```
223
+
224
+ ```markdown
225
+ ---
226
+ name: "Reviewer"
227
+ chain:
228
+ - id: review
229
+ kind: text
230
+ prompt_section: step:review
231
+ output:
232
+ format: json
233
+ file: out/reviewer.json
234
+ ---
235
+
236
+ ## step:review
237
+
238
+ Review the inline output produced by the writer and provide a brief critique.
239
+ ```
@@ -2,6 +2,8 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ from typing import Literal
6
+
5
7
  from pydantic import BaseModel, Field
6
8
 
7
9
  from quick_agent.models.chain_step_spec import ChainStepSpec
@@ -19,4 +21,5 @@ class AgentSpec(BaseModel):
19
21
  chain: list[ChainStepSpec]
20
22
  output: OutputSpec = Field(default_factory=OutputSpec)
21
23
  handoff: HandoffSpec = Field(default_factory=HandoffSpec)
24
+ nested_output: Literal["inline", "file"] = "inline"
22
25
  safe_dir: str | None = None
@@ -2,13 +2,148 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import logging
6
+ import re
5
7
  from dataclasses import dataclass
8
+ from pathlib import Path
9
+
10
+ import frontmatter
6
11
 
7
12
  from quick_agent.models.agent_spec import AgentSpec
8
13
 
9
14
 
15
+ logger = logging.getLogger(__name__)
16
+
17
+ SECTION_HEADER_RE = re.compile(r"^(#{1,6})\s+(.+?)\s*$", re.MULTILINE)
18
+ STEP_ID_RE = re.compile(r"^[A-Za-z0-9_-]+$")
19
+
20
+
10
21
  @dataclass
11
22
  class LoadedAgentFile:
12
23
  spec: AgentSpec
13
- body: str
24
+ instructions: str
25
+ system_prompt: str
14
26
  step_prompts: dict[str, str] # prompt_section -> markdown chunk
27
+
28
+ def __init__(self, agent: Path | str) -> None:
29
+ post, source_label = load_agent_frontmatter(agent)
30
+ spec = AgentSpec.model_validate(post.metadata)
31
+ sections = parse_agent_sections(post.content)
32
+ if sections.first_section_start is not None and (
33
+ sections.instructions_start is not None or sections.system_prompt_start is not None
34
+ ):
35
+ preamble = post.content[: sections.first_section_start]
36
+ if preamble.strip():
37
+ logger.warning("Ignored text before instructions or system prompt in %s", source_label)
38
+ if not sections.step_prompts and sections.instructions_start is None and sections.system_prompt_start is None:
39
+ raise ValueError("Agent markdown must include instructions, system prompt, or step sections.")
40
+ self.spec = spec
41
+ self.instructions = sections.instructions
42
+ self.system_prompt = sections.system_prompt
43
+ self.step_prompts = sections.step_prompts
44
+
45
+ @classmethod
46
+ def from_parts(
47
+ cls,
48
+ *,
49
+ spec: AgentSpec,
50
+ instructions: str,
51
+ system_prompt: str,
52
+ step_prompts: dict[str, str],
53
+ ) -> "LoadedAgentFile":
54
+ obj = cls.__new__(cls)
55
+ obj.spec = spec
56
+ obj.instructions = instructions
57
+ obj.system_prompt = system_prompt
58
+ obj.step_prompts = step_prompts
59
+ return obj
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class ParsedAgentSections:
64
+ instructions: str
65
+ system_prompt: str
66
+ step_prompts: dict[str, str]
67
+ instructions_start: int | None
68
+ system_prompt_start: int | None
69
+ first_section_start: int | None
70
+
71
+
72
+ def load_agent_frontmatter(agent: Path | str) -> tuple[frontmatter.Post, str]:
73
+ if isinstance(agent, Path):
74
+ post = frontmatter.load(str(agent))
75
+ return post, str(agent)
76
+ agent_path = Path(agent)
77
+ if agent_path.exists():
78
+ post = frontmatter.load(str(agent_path))
79
+ return post, str(agent_path)
80
+ post = frontmatter.loads(agent)
81
+ return post, "<inline>"
82
+
83
+
84
+ def normalize_header_text(header_text: str) -> str:
85
+ normalized = header_text.strip().lower().replace("_", " ")
86
+ return re.sub(r"\s+", " ", normalized)
87
+
88
+
89
+ def classify_section_header(header_text: str) -> tuple[str, str] | None:
90
+ header = header_text.strip()
91
+ if ":" in header:
92
+ prefix, step_id = header.split(":", 1)
93
+ if prefix.strip().lower() == "step":
94
+ step_id = step_id.strip()
95
+ if step_id and STEP_ID_RE.match(step_id):
96
+ return ("step", f"step:{step_id}")
97
+ normalized = normalize_header_text(header_text)
98
+ if normalized == "instructions":
99
+ return ("instructions", "instructions")
100
+ if normalized == "system prompt":
101
+ return ("system_prompt", "system_prompt")
102
+ return None
103
+
104
+
105
+ def parse_agent_sections(markdown_body: str) -> ParsedAgentSections:
106
+ matches = list(SECTION_HEADER_RE.finditer(markdown_body))
107
+ recognized: list[tuple[str, str, int, int]] = []
108
+ instructions_start: int | None = None
109
+ system_prompt_start: int | None = None
110
+
111
+ for match in matches:
112
+ header_text = match.group(2).strip()
113
+ classified = classify_section_header(header_text)
114
+ if classified is None:
115
+ continue
116
+ kind, key = classified
117
+ recognized.append((kind, key, match.start(), match.end()))
118
+ if kind == "instructions" and instructions_start is None:
119
+ instructions_start = match.start()
120
+ if kind == "system_prompt" and system_prompt_start is None:
121
+ system_prompt_start = match.start()
122
+
123
+ instructions = ""
124
+ system_prompt = ""
125
+ step_prompts: dict[str, str] = {}
126
+
127
+ for index, (kind, key, _start, end) in enumerate(recognized):
128
+ section_start = end
129
+ next_index = index + 1
130
+ section_end = recognized[next_index][2] if next_index < len(recognized) else len(markdown_body)
131
+ content = markdown_body[section_start:section_end].strip()
132
+ if kind == "instructions":
133
+ if not instructions:
134
+ instructions = content
135
+ elif kind == "system_prompt":
136
+ if not system_prompt:
137
+ system_prompt = content
138
+ else:
139
+ step_prompts[key] = content
140
+
141
+ first_section_start = recognized[0][2] if recognized else None
142
+ return ParsedAgentSections(
143
+ instructions=instructions,
144
+ system_prompt=system_prompt,
145
+ step_prompts=step_prompts,
146
+ instructions_start=instructions_start,
147
+ system_prompt_start=system_prompt_start,
148
+ first_section_start=first_section_start,
149
+ )
@@ -7,4 +7,4 @@ from pydantic import BaseModel
7
7
 
8
8
  class OutputSpec(BaseModel):
9
9
  format: str = "json" # "json" or "markdown"
10
- file: str = "out/result.json"
10
+ file: str | None = None