quick-agent 0.1.1__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 (45) hide show
  1. quick_agent/__init__.py +6 -0
  2. quick_agent/agent_call_tool.py +44 -0
  3. quick_agent/agent_registry.py +75 -0
  4. quick_agent/agent_tools.py +41 -0
  5. quick_agent/cli.py +44 -0
  6. quick_agent/directory_permissions.py +49 -0
  7. quick_agent/io_utils.py +36 -0
  8. quick_agent/json_utils.py +37 -0
  9. quick_agent/models/__init__.py +23 -0
  10. quick_agent/models/agent_spec.py +22 -0
  11. quick_agent/models/chain_step_spec.py +14 -0
  12. quick_agent/models/handoff_spec.py +13 -0
  13. quick_agent/models/loaded_agent_file.py +14 -0
  14. quick_agent/models/model_spec.py +14 -0
  15. quick_agent/models/output_spec.py +10 -0
  16. quick_agent/models/run_input.py +14 -0
  17. quick_agent/models/tool_impl_spec.py +11 -0
  18. quick_agent/models/tool_json.py +14 -0
  19. quick_agent/orchestrator.py +35 -0
  20. quick_agent/prompting.py +28 -0
  21. quick_agent/quick_agent.py +313 -0
  22. quick_agent/schemas/outputs.py +55 -0
  23. quick_agent/tools/__init__.py +0 -0
  24. quick_agent/tools/filesystem/__init__.py +0 -0
  25. quick_agent/tools/filesystem/adapter.py +26 -0
  26. quick_agent/tools/filesystem/read_text.py +16 -0
  27. quick_agent/tools/filesystem/write_text.py +19 -0
  28. quick_agent/tools/filesystem.read_text/tool.json +10 -0
  29. quick_agent/tools/filesystem.write_text/tool.json +10 -0
  30. quick_agent/tools_loader.py +76 -0
  31. quick_agent-0.1.1.data/data/quick_agent/agents/function-spec-validator.md +109 -0
  32. quick_agent-0.1.1.data/data/quick_agent/agents/subagent-validate-eval-list.md +122 -0
  33. quick_agent-0.1.1.data/data/quick_agent/agents/subagent-validator-contains.md +115 -0
  34. quick_agent-0.1.1.data/data/quick_agent/agents/template.md +88 -0
  35. quick_agent-0.1.1.dist-info/METADATA +918 -0
  36. quick_agent-0.1.1.dist-info/RECORD +45 -0
  37. quick_agent-0.1.1.dist-info/WHEEL +5 -0
  38. quick_agent-0.1.1.dist-info/entry_points.txt +2 -0
  39. quick_agent-0.1.1.dist-info/licenses/LICENSE +674 -0
  40. quick_agent-0.1.1.dist-info/top_level.txt +2 -0
  41. tests/test_agent.py +196 -0
  42. tests/test_directory_permissions.py +89 -0
  43. tests/test_integration.py +221 -0
  44. tests/test_orchestrator.py +797 -0
  45. tests/test_tools.py +25 -0
@@ -0,0 +1,6 @@
1
+ """Public package exports."""
2
+
3
+ from quick_agent.orchestrator import Orchestrator
4
+ from quick_agent.quick_agent import QuickAgent
5
+
6
+ __all__ = ["Orchestrator", "QuickAgent"]
@@ -0,0 +1,44 @@
1
+ """Callable tool for inter-agent calls."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any, Awaitable, Callable
7
+
8
+ from pydantic import BaseModel
9
+
10
+
11
+ class AgentCallTool:
12
+ def __init__(
13
+ self,
14
+ call_agent: Callable[[str, Path], Awaitable[BaseModel | str]],
15
+ run_input_source_path: str,
16
+ ) -> None:
17
+ self._call_agent = call_agent
18
+ self._run_input_source_path = run_input_source_path
19
+ self.__name__ = "agent_call"
20
+
21
+ def _resolve_input_file(self, raw_input_file: str) -> Path:
22
+ cleaned = raw_input_file.strip()
23
+ if (
24
+ (cleaned.startswith("\"") and cleaned.endswith("\""))
25
+ or (cleaned.startswith("'") and cleaned.endswith("'"))
26
+ ):
27
+ cleaned = cleaned[1:-1]
28
+ base_dir = Path(self._run_input_source_path).parent
29
+ cleaned = cleaned.replace("{base_directory}", str(base_dir))
30
+ path = Path(cleaned)
31
+ if not path.is_absolute():
32
+ path = base_dir / path
33
+ return path
34
+
35
+ async def __call__(self, agent: str, input_file: str) -> dict[str, Any]:
36
+ """
37
+ Call another agent by ID with an input file path.
38
+ Returns JSON-serializable dict output if structured, else {"text": "..."}.
39
+ """
40
+ resolved_input = self._resolve_input_file(input_file)
41
+ out = await self._call_agent(agent, resolved_input)
42
+ if isinstance(out, BaseModel):
43
+ return out.model_dump()
44
+ return {"text": out}
@@ -0,0 +1,75 @@
1
+ """Agent registry and parsing utilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from pathlib import Path
7
+
8
+ import frontmatter
9
+
10
+ from quick_agent.models.agent_spec import AgentSpec
11
+ from quick_agent.models.loaded_agent_file import LoadedAgentFile
12
+
13
+
14
+ def split_step_sections(markdown_body: str) -> dict[str, str]:
15
+ """
16
+ Extracts blocks that begin with headings "## step:<id>".
17
+ Returns mapping: "step:<id>" -> content for that step (excluding heading line).
18
+ """
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)
37
+
38
+
39
+ class AgentRegistry:
40
+ def __init__(self, agent_roots: list[Path]):
41
+ self.agent_roots = agent_roots
42
+ self._cache: dict[str, LoadedAgentFile] = {}
43
+ self._index: dict[str, Path] | None = None
44
+
45
+ def _build_index(self) -> dict[str, Path]:
46
+ index: dict[str, Path] = {}
47
+ for root in self.agent_roots:
48
+ if not root.exists():
49
+ continue
50
+ for path in root.rglob("*.md"):
51
+ agent_id = path.stem
52
+ if agent_id in index:
53
+ continue
54
+ index[agent_id] = path
55
+ return index
56
+
57
+ def _get_index(self) -> dict[str, Path]:
58
+ if self._index is None:
59
+ self._index = self._build_index()
60
+ return self._index
61
+
62
+ def list_agents(self) -> list[str]:
63
+ index = self._get_index()
64
+ return sorted(index.keys())
65
+
66
+ def get(self, agent_id: str) -> LoadedAgentFile:
67
+ if agent_id in self._cache:
68
+ return self._cache[agent_id]
69
+ index = self._get_index()
70
+ path = index.get(agent_id)
71
+ if path is None:
72
+ raise FileNotFoundError(f"Agent not found: {agent_id} (searched: {self.agent_roots})")
73
+ loaded = load_agent_file(path)
74
+ self._cache[agent_id] = loaded
75
+ return loaded
@@ -0,0 +1,41 @@
1
+ """Toolset builder and agent-call injection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any, Awaitable, Callable
7
+
8
+ from pydantic import BaseModel
9
+ from pydantic_ai.toolsets import FunctionToolset
10
+
11
+ from quick_agent.agent_call_tool import AgentCallTool
12
+ from quick_agent.directory_permissions import DirectoryPermissions
13
+ from quick_agent.tools_loader import load_tools
14
+
15
+
16
+ class AgentTools:
17
+ def __init__(self, tool_roots: list[Path]) -> None:
18
+ self._tool_roots = tool_roots
19
+
20
+ def build_toolset(self, tool_ids: list[str], permissions: DirectoryPermissions) -> FunctionToolset[Any]:
21
+ tool_ids_for_disk = [tool_id for tool_id in tool_ids if tool_id != "agent.call"]
22
+ if tool_ids_for_disk:
23
+ return load_tools(self._tool_roots, tool_ids_for_disk, permissions)
24
+ return FunctionToolset()
25
+
26
+ def maybe_inject_agent_call(
27
+ self,
28
+ tool_ids: list[str],
29
+ toolset: FunctionToolset[Any],
30
+ run_input_source_path: str,
31
+ call_agent: Callable[[str, Path], Awaitable[BaseModel | str]],
32
+ ) -> None:
33
+ if "agent.call" not in tool_ids:
34
+ return
35
+
36
+ tool = AgentCallTool(call_agent, run_input_source_path)
37
+ toolset.add_function(
38
+ func=tool.__call__,
39
+ name="agent_call",
40
+ description="Call another agent and return its output.",
41
+ )
quick_agent/cli.py ADDED
@@ -0,0 +1,44 @@
1
+ """CLI entrypoint."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ from pathlib import Path
7
+
8
+ from pydantic import BaseModel
9
+
10
+ from quick_agent.orchestrator import Orchestrator
11
+
12
+
13
+ def main() -> None:
14
+ parser = argparse.ArgumentParser()
15
+ parser.add_argument("--agents-dir", type=str, default="agents")
16
+ parser.add_argument("--tools-dir", type=str, default="tools")
17
+ parser.add_argument("--safe-dir", type=str, default="safe")
18
+ parser.add_argument("--agent", type=str, required=True)
19
+ parser.add_argument("--input", type=str, required=True)
20
+ parser.add_argument("--tool", action="append", default=[], help="Extra tool IDs to add at runtime")
21
+ args = parser.parse_args()
22
+
23
+ package_root = Path(__file__).resolve().parent
24
+ system_agents_dir = package_root / "agents"
25
+ system_tools_dir = package_root / "tools"
26
+ user_agents_dir = Path(args.agents_dir)
27
+ user_tools_dir = Path(args.tools_dir)
28
+
29
+ agent_roots = [user_agents_dir, system_agents_dir]
30
+ tool_roots = [user_tools_dir, system_tools_dir]
31
+
32
+ orch = Orchestrator(agent_roots, tool_roots, Path(args.safe_dir))
33
+
34
+ # Async entrypoint
35
+ import anyio
36
+
37
+ async def runner():
38
+ return await orch.run(args.agent, Path(args.input), extra_tools=args.tool)
39
+
40
+ out = anyio.run(runner)
41
+ if isinstance(out, BaseModel):
42
+ print(out.model_dump_json(indent=2))
43
+ else:
44
+ print(out)
@@ -0,0 +1,49 @@
1
+ """Directory permission enforcement for file access."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+
8
+ class DirectoryPermissions:
9
+ def __init__(self, root: Path) -> None:
10
+ self._root = root.expanduser().resolve(strict=False)
11
+
12
+ @property
13
+ def root(self) -> Path:
14
+ return self._root
15
+
16
+ def scoped(self, directory: str | None) -> "DirectoryPermissions":
17
+ if directory:
18
+ candidate = (self._root / directory).expanduser().resolve(strict=False)
19
+ root_resolved = self._root.expanduser().resolve(strict=False)
20
+ if not candidate.is_relative_to(root_resolved):
21
+ raise ValueError(f"Scoped directory {directory!r} escapes safe root {root_resolved}.")
22
+ return DirectoryPermissions(candidate)
23
+ return self
24
+
25
+ def resolve(self, path: Path, *, for_write: bool) -> Path:
26
+ target = path
27
+ if not target.is_absolute():
28
+ target = self._root / target
29
+ resolved = target.expanduser().resolve(strict=False)
30
+ root_resolved = self._root.expanduser().resolve(strict=False)
31
+ if not resolved.is_relative_to(root_resolved):
32
+ raise PermissionError(f"Path {resolved} is outside safe directory {root_resolved}.")
33
+ if for_write:
34
+ root_resolved.mkdir(parents=True, exist_ok=True)
35
+ return resolved
36
+
37
+ def can_read(self, path: Path) -> bool:
38
+ try:
39
+ self.resolve(path, for_write=False)
40
+ except PermissionError:
41
+ return False
42
+ return True
43
+
44
+ def can_write(self, path: Path) -> bool:
45
+ try:
46
+ self.resolve(path, for_write=False)
47
+ except PermissionError:
48
+ return False
49
+ return True
@@ -0,0 +1,36 @@
1
+ """Input/output helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ from quick_agent.directory_permissions import DirectoryPermissions
9
+ from quick_agent.models.run_input import RunInput
10
+
11
+
12
+ def load_input(path: Path, permissions: DirectoryPermissions) -> RunInput:
13
+ safe_path = permissions.resolve(path, for_write=False)
14
+ if not safe_path.exists():
15
+ raise FileNotFoundError(safe_path)
16
+
17
+ if safe_path.suffix.lower() == ".json":
18
+ raw = json.loads(safe_path.read_text(encoding="utf-8"))
19
+ return RunInput(source_path=str(safe_path), kind="json", text=json.dumps(raw, indent=2), data=raw)
20
+ txt = safe_path.read_text(encoding="utf-8")
21
+ return RunInput(source_path=str(safe_path), kind="text", text=txt, data=None)
22
+
23
+
24
+ def ensure_parent_dir(path: Path, permissions: DirectoryPermissions) -> Path:
25
+ safe_path = permissions.resolve(path, for_write=True)
26
+ safe_path.parent.mkdir(parents=True, exist_ok=True)
27
+ return safe_path
28
+
29
+
30
+ def write_output(
31
+ path: Path,
32
+ content: str,
33
+ permissions: DirectoryPermissions,
34
+ ) -> None:
35
+ safe_path = ensure_parent_dir(path, permissions)
36
+ safe_path.write_text(content, encoding="utf-8")
@@ -0,0 +1,37 @@
1
+ """JSON parsing helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ def extract_first_json_object(text: str) -> str:
7
+ """
8
+ Extract the first top-level JSON object from text.
9
+ This is a fallback for models that wrap JSON in extra text.
10
+ """
11
+ start = text.find("{")
12
+ if start == -1:
13
+ raise ValueError("No JSON object found in model output.")
14
+
15
+ depth = 0
16
+ in_string = False
17
+ escape = False
18
+ for i in range(start, len(text)):
19
+ ch = text[i]
20
+ if in_string:
21
+ if escape:
22
+ escape = False
23
+ elif ch == "\\":
24
+ escape = True
25
+ elif ch == "\"":
26
+ in_string = False
27
+ else:
28
+ if ch == "\"":
29
+ in_string = True
30
+ elif ch == "{":
31
+ depth += 1
32
+ elif ch == "}":
33
+ depth -= 1
34
+ if depth == 0:
35
+ return text[start : i + 1]
36
+
37
+ raise ValueError("Unbalanced JSON object in model output.")
@@ -0,0 +1,23 @@
1
+ """Model types for agent configuration and runtime."""
2
+
3
+ from quick_agent.models.agent_spec import AgentSpec
4
+ from quick_agent.models.chain_step_spec import ChainStepSpec
5
+ from quick_agent.models.handoff_spec import HandoffSpec
6
+ from quick_agent.models.loaded_agent_file import LoadedAgentFile
7
+ from quick_agent.models.model_spec import ModelSpec
8
+ from quick_agent.models.output_spec import OutputSpec
9
+ from quick_agent.models.run_input import RunInput
10
+ from quick_agent.models.tool_impl_spec import ToolImplSpec
11
+ from quick_agent.models.tool_json import ToolJson
12
+
13
+ __all__ = [
14
+ "AgentSpec",
15
+ "ChainStepSpec",
16
+ "HandoffSpec",
17
+ "LoadedAgentFile",
18
+ "ModelSpec",
19
+ "OutputSpec",
20
+ "RunInput",
21
+ "ToolImplSpec",
22
+ "ToolJson",
23
+ ]
@@ -0,0 +1,22 @@
1
+ """Pydantic model for agent frontmatter spec."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+ from quick_agent.models.chain_step_spec import ChainStepSpec
8
+ from quick_agent.models.handoff_spec import HandoffSpec
9
+ from quick_agent.models.model_spec import ModelSpec
10
+ from quick_agent.models.output_spec import OutputSpec
11
+
12
+
13
+ class AgentSpec(BaseModel):
14
+ name: str
15
+ description: str = ""
16
+ model: ModelSpec = Field(default_factory=ModelSpec)
17
+ tools: list[str] = Field(default_factory=list)
18
+ schemas: dict[str, str] = Field(default_factory=dict) # alias -> "module:ClassName"
19
+ chain: list[ChainStepSpec]
20
+ output: OutputSpec = Field(default_factory=OutputSpec)
21
+ handoff: HandoffSpec = Field(default_factory=HandoffSpec)
22
+ safe_dir: str | None = None
@@ -0,0 +1,14 @@
1
+ """Pydantic model for a single chain step."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ from pydantic import BaseModel
8
+
9
+
10
+ class ChainStepSpec(BaseModel):
11
+ id: str
12
+ kind: str # "text" or "structured" (you may extend: "parallel_map", "fanout", etc.)
13
+ prompt_section: str
14
+ output_schema: Optional[str] = None
@@ -0,0 +1,13 @@
1
+ """Pydantic model for handoff configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ from pydantic import BaseModel
8
+
9
+
10
+ class HandoffSpec(BaseModel):
11
+ enabled: bool = False
12
+ agent_id: Optional[str] = None
13
+ input_mode: str = "final_output_json"
@@ -0,0 +1,14 @@
1
+ """Loaded agent markdown plus parsed metadata."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from quick_agent.models.agent_spec import AgentSpec
8
+
9
+
10
+ @dataclass
11
+ class LoadedAgentFile:
12
+ spec: AgentSpec
13
+ body: str
14
+ step_prompts: dict[str, str] # prompt_section -> markdown chunk
@@ -0,0 +1,14 @@
1
+ """Pydantic model for LLM configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+
8
+ class ModelSpec(BaseModel):
9
+ provider: str = Field(default="openai-compatible")
10
+ base_url: str = Field(default="https://api.openai.com/v1")
11
+ api_key_env: str = Field(default="OPENAI_API_KEY")
12
+ model_name: str = Field(default="gpt-5.2")
13
+ temperature: float = 0.2
14
+ max_tokens: int = 2048
@@ -0,0 +1,10 @@
1
+ """Pydantic model for output configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pydantic import BaseModel
6
+
7
+
8
+ class OutputSpec(BaseModel):
9
+ format: str = "json" # "json" or "markdown"
10
+ file: str = "out/result.json"
@@ -0,0 +1,14 @@
1
+ """Pydantic model for runtime input."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Optional
6
+
7
+ from pydantic import BaseModel
8
+
9
+
10
+ class RunInput(BaseModel):
11
+ source_path: str
12
+ kind: str # "json" or "text"
13
+ text: str
14
+ data: Optional[dict[str, Any]] = None
@@ -0,0 +1,11 @@
1
+ """Pydantic model for tool implementation metadata."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pydantic import BaseModel
6
+
7
+
8
+ class ToolImplSpec(BaseModel):
9
+ kind: str # "python"
10
+ module: str
11
+ function: str
@@ -0,0 +1,14 @@
1
+ """Pydantic model for tool.json files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pydantic import BaseModel
6
+
7
+ from quick_agent.models.tool_impl_spec import ToolImplSpec
8
+
9
+
10
+ class ToolJson(BaseModel):
11
+ id: str
12
+ name: str
13
+ description: str = ""
14
+ impl: ToolImplSpec
@@ -0,0 +1,35 @@
1
+ """Helper for running agents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from pydantic import BaseModel
8
+
9
+ from quick_agent.agent_registry import AgentRegistry
10
+ from quick_agent.agent_tools import AgentTools
11
+ from quick_agent.directory_permissions import DirectoryPermissions
12
+ from quick_agent.quick_agent import QuickAgent
13
+
14
+
15
+ class Orchestrator:
16
+ def __init__(
17
+ self,
18
+ agent_roots: list[Path],
19
+ tool_roots: list[Path],
20
+ safe_dir: Path,
21
+ ) -> None:
22
+ self.registry = AgentRegistry(agent_roots)
23
+ self.tools = AgentTools(tool_roots)
24
+ self.directory_permissions = DirectoryPermissions(safe_dir)
25
+
26
+ async def run(self, agent_id: str, input_path: Path, extra_tools: list[str] | None = None) -> BaseModel | str:
27
+ agent = QuickAgent(
28
+ registry=self.registry,
29
+ tools=self.tools,
30
+ directory_permissions=self.directory_permissions,
31
+ agent_id=agent_id,
32
+ input_path=input_path,
33
+ extra_tools=extra_tools,
34
+ )
35
+ return await agent.run()
@@ -0,0 +1,28 @@
1
+ """Prompt composition helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+ from quick_agent.models.run_input import RunInput
9
+
10
+
11
+ def make_user_prompt(step_prompt: str, run_input: RunInput, state: dict[str, Any]) -> str:
12
+ """
13
+ Creates a consistent user prompt payload. Consistency helps prefix-caching backends.
14
+ """
15
+ # Keep the preamble stable; append variable fields below.
16
+ return f"""# Task Input
17
+ source_path: {run_input.source_path}
18
+ kind: {run_input.kind}
19
+
20
+ ## Input Content
21
+ {run_input.text}
22
+
23
+ ## Chain State (JSON)
24
+ {json.dumps(state, indent=2)}
25
+
26
+ ## Step Instructions
27
+ {step_prompt}
28
+ """