Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
500 changes: 301 additions & 199 deletions agentspring.egg-info/PKG-INFO

Large diffs are not rendered by default.

21 changes: 4 additions & 17 deletions agentspring.egg-info/SOURCES.txt
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
README.md
pyproject.toml
setup.py
agentspring/README.md
agentspring/__init__.py
agentspring/api.py
agentspring/api_versioning.py
Expand All @@ -13,7 +12,6 @@ agentspring/logging_config.py
agentspring/metrics.py
agentspring/models.py
agentspring/multi_tenancy.py
agentspring/orchestration.py
agentspring/task_base.py
agentspring/tasks.py
agentspring.egg-info/PKG-INFO
Expand All @@ -27,13 +25,10 @@ agentspring/goals/__init__.py
agentspring/llm/__init__.py
agentspring/llm/base.py
agentspring/llm/registry.py
agentspring/loop/__init__.py
agentspring/memory/__init__.py
agentspring/planner/__init__.py
agentspring/tools/__init__.py
agentspring/tools/business.py
agentspring/tools/communication.py
agentspring/tools/data_storage.py
agentspring/tools/media.py
agentspring/tools/system.py
agentspring/tools/web_search.py
agentspring/workflow/__init__.py
tests/__init__.py
tests/test_agent_workflow.py
Expand All @@ -49,14 +44,6 @@ tests/test_llm_providers.py
tests/test_metrics.py
tests/test_models.py
tests/test_multi_tenancy.py
tests/test_orchestration.py
tests/test_quickstart.py
tests/test_scenarios.py
tests/test_tasks.py
tests/test_tools_business.py
tests/test_tools_communication.py
tests/test_tools_data_storage.py
tests/test_tools_init.py
tests/test_tools_media.py
tests/test_tools_system.py
tests/test_tools_web_search.py
tests/test_tasks.py
49 changes: 49 additions & 0 deletions agentspring/loop/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""Agent loop (minimal): perceive -> plan -> act -> (optional reflect)"""
from __future__ import annotations

import asyncio
from typing import Any, Dict, Optional, TYPE_CHECKING

# Only import heavy/optional modules for type checking.
# At runtime these imports are skipped, preventing import-time failures.
if TYPE_CHECKING:
from . import Agent
from ..planner import Planner
from ..workflow import Workflow


class AgentLoop:
def __init__(
self,
planner: "Planner",
agent: Optional["Agent"] = None,
max_iterations: int = 1,
enable_reflection: bool = False,
) -> None:
self.planner = planner
self.agent = agent
self.max_iterations = max_iterations
self.enable_reflection = enable_reflection

async def run_async(self, user_prompt: str) -> Dict[str, Any]:
# Lazy import here too, in case workflow has optional deps
from ..workflow import Workflow # type: ignore

wf: "Workflow" = await self.planner.plan_workflow(user_prompt)
result = await wf.execute()
return {"status": "completed", "workflow": wf.to_dict(), "result": result}

def run(self, user_prompt: str) -> Dict[str, Any]:
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = None

if loop and loop.is_running():
# In an async context already
return loop.create_task(self.run_async(user_prompt)) # type: ignore

return asyncio.run(self.run_async(user_prompt))


__all__ = ["AgentLoop"]
27 changes: 27 additions & 0 deletions agentspring/memory/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"""Memory interfaces (stubs): short-term & long-term"""
from __future__ import annotations

from dataclasses import dataclass, field
from typing import Any, Dict, Optional


class Memory:
def remember(self, key: str, value: Any) -> None:
raise NotImplementedError

def recall(self, key: str, default: Any = None) -> Any:
raise NotImplementedError


@dataclass
class InMemoryMemory(Memory):
_store: Dict[str, Any] = field(default_factory=dict)

def remember(self, key: str, value: Any) -> None:
self._store[key] = value

def recall(self, key: str, default: Any = None) -> Any:
return self._store.get(key, default)


__all__ = ["Memory", "InMemoryMemory"]
259 changes: 259 additions & 0 deletions agentspring/planner/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
"""
Planner: turns natural-language goals into a Workflow.
- Uses ToolRegistry schemas to constrain tool choices/params
- Uses an LLM provider (if available) to propose a JSON plan
- Falls back to a simple keyword heuristic if no LLM is configured
"""
from __future__ import annotations

import json
import re
from typing import Any, Dict, List, Optional, TYPE_CHECKING
from pydantic import BaseModel, Field

from ..tools import tool_registry, ToolRegistry
from ..workflow import Workflow, NodeType

if TYPE_CHECKING:
# For type-checkers only; not executed at runtime
from ..llm import LLMProvider


DEFAULT_PROMPT = """You are a senior workflow planner. Given a user instruction
and the available tools (with names and parameter schemas), output a JSON object with this schema:

{
"steps": [
{
"id": "step-1",
"tool_name": "<one of the allowed tool names>",
"parameters": { ... },
"reasoning": "why this step",
"depends_on": ["<optional previous step ids>"]
}
]
}

Rules:
- Only use tool names from the provided list.
- If a parameter is not needed, omit it.
- Prefer 1-5 concise steps.
- Do NOT include any text outside the JSON object.
Tools:
{tools}
User instruction: {prompt}
"""

def _extract_main_text(s: str) -> str:
"""
If the user provided a quoted block, prefer that as the text payload.
Supports triple or single/double quotes. Falls back to the original.
"""
# triple-quoted block
m = re.search(r'"""([\s\S]*?)"""', s)
if m and m.group(1).strip():
return m.group(1).strip()

# longest single/double-quoted block
blocks = re.findall(r'"([^"]+)"|\'([^\']+)\'', s)
merged = [b[0] or b[1] for b in blocks]
if merged:
# pick the longest quoted span
return max(merged, key=len).strip()

return s.strip()

class PlanStep(BaseModel):
id: str
tool_name: str
parameters: Dict[str, Any] = Field(default_factory=dict)
reasoning: Optional[str] = None
depends_on: List[str] = Field(default_factory=list)


class Plan(BaseModel):
steps: List[PlanStep] = Field(default_factory=list)


class Planner:
def __init__(
self,
llm: Optional["LLMProvider"] = None,
tools: Optional[ToolRegistry] = None,
prompt_template: str = DEFAULT_PROMPT,
) -> None:
self.tools = tools or tool_registry
self.llm = llm if llm is not None else self._try_get_default_llm()
self.prompt_template = prompt_template

def _try_get_default_llm(self) -> Optional["LLMProvider"]:
"""Lazy-import the default LLM provider to avoid import-time failures."""
try:
from ..llm import get_provider # type: ignore
return get_provider()
except Exception:
return None

def _tools_markdown(self) -> str:
schemas = self.tools.get_all_schemas()
lines: List[str] = []
for name, schema in schemas.items():
desc = (schema.description or "").strip()
params = schema.parameters or {}
lines.append(f"- {name}: {desc}")
if params:
lines.append(" params:" + json.dumps(params))
return "\n".join(lines)

def _prompt(self, user_prompt: str) -> str:
# Avoid str.format() so literal JSON braces don't break the template
return (
self.prompt_template
.replace("{tools}", self._tools_markdown())
.replace("{prompt}", user_prompt)
)

def _extract_json(self, text: str) -> str:
# Prefer fenced code block
m = re.search(r"```(?:json\n)?([\s\S]*?)\n```", text)
if m:
return m.group(1).strip()
# Fallback to first {...} blob
m = re.search(r"\{[\s\S]*\}", text)
if m:
return m.group(0)
raise ValueError("No JSON object found in LLM output")

async def plan(self, user_prompt: str) -> Plan:
# If no LLM, return a trivial heuristic plan
if self.llm is None:
return self._heuristic_plan(user_prompt)

raw = await self.llm.generate_async(self._prompt(user_prompt))
try:
data = json.loads(self._extract_json(raw))
return Plan(**data)
except Exception as e:
# Fallback to heuristic if parsing fails
return self._heuristic_plan(user_prompt, error=str(e))

def _heuristic_plan(self, user_prompt: str, error: Optional[str] = None) -> Plan:
# Very simple heuristic: pick the first available tool and pass the user text
schemas = self.tools.get_all_schemas()
tool_name = next(iter(schemas.keys()), None)
if tool_name is None:
return Plan(steps=[])
return Plan(
steps=[
PlanStep(
id="step-1",
tool_name=tool_name,
parameters={"text": user_prompt},
reasoning=(
"heuristic fallback"
if error is None
else f"heuristic due to {error}"
),
depends_on=[],
)
]
)

def build_workflow(
self,
plan: Plan,
workflow_id: str = "wf-1",
name: str = "Planned Workflow",
default_input: Optional[str] = None,
) -> Workflow:
"""
Build a Workflow from a Plan, with safety:
- skip unknown tools
- normalize duplicate/empty step ids
- drop deps to unknown steps
- auto-fill a missing text-like parameter from `default_input`
"""
wf = Workflow(workflow_id=workflow_id, name=name)
wf.tools = self.tools

# at the start of build_workflow(), after wf.tools = self.tools
text_param_names = ("text", "input", "prompt", "content")
schemas = self.tools.get_all_schemas()
main_text = _extract_main_text(default_input or "") if default_input else None


# Keep only steps with known tools
kept: List[PlanStep] = [s for s in plan.steps if self.tools.get_tool(s.tool_name)]
if not kept:
fallback = self._heuristic_plan("No valid steps/tools in plan")
kept = fallback.steps

# Normalize IDs
used_ids: set[str] = set()
normalized: List[PlanStep] = []
next_i = 1
for step in kept:
sid = (getattr(step, "id", "") or "").strip()
if not sid or sid in used_ids:
while True:
sid = f"step-{next_i}"
next_i += 1
if sid not in used_ids:
break
used_ids.add(sid)
normalized.append(
PlanStep(
id=sid,
tool_name=step.tool_name,
parameters=step.parameters or {},
reasoning=step.reasoning,
depends_on=list(step.depends_on or []),
)
)

valid_ids = used_ids
for step in normalized:
step.depends_on = [d for d in (step.depends_on or []) if d in valid_ids]

# Autocomplete typical text params when missing
schemas = self.tools.get_all_schemas()
text_param_names = ("text", "input", "prompt", "content")

for step in normalized:
params = dict(step.parameters or {})
schema = schemas.get(step.tool_name)
if schema and isinstance(schema.parameters, dict):
needs_text = any(name in schema.parameters for name in text_param_names)
missing_text = all(name not in params for name in text_param_names)
if needs_text and missing_text and main_text:
target_name = next(
(n for n in ("text", "input", "prompt", "content") if n in schema.parameters),
"text",
)
params[target_name] = main_text
step.parameters = params


# Add nodes
for step in normalized:
wf.add_node(
node_id=step.id,
node_type=NodeType.TOOL,
config={"tool_name": step.tool_name, "parameters": step.parameters},
dependencies=step.depends_on,
)

return wf

async def plan_workflow(self, user_prompt: str, workflow_id: str = "wf-1") -> Workflow:
plan = await self.plan(user_prompt)
return self.build_workflow(
plan,
workflow_id=workflow_id,
name=f"Plan for: {user_prompt[:50]}",
default_input=user_prompt, # <-- pass original instruction for autofill
)



__all__ = ["Planner", "Plan", "PlanStep"]
Binary file modified dump.rdb
Binary file not shown.
Loading