Skip to content
Draft
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
57 changes: 49 additions & 8 deletions sdk/python/jamjet/agent_tool.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"""Agent-tool configuration objects and their IR serialization."""

from __future__ import annotations

from dataclasses import dataclass
Expand All @@ -6,7 +8,19 @@

@dataclass
class AgentToolDef:
"""A wrapped agent that can be used as a tool by other agents."""
"""Configuration for an agent-tool invocation, without executing the agent.

Attributes:
agent_uri: Explicit agent URI, or ``"auto"`` for an automatic target.
mode: Invocation mode: ``"sync"``, ``"streaming"``, or ``"conversational"``.
description: Human-readable description included in the IR fragment.
budget: Optional ``max_cost_usd`` and ``max_tokens`` limits, passed through
without validation. An empty mapping is omitted from the IR.
max_turns: Conversational turn limit. Serialization uses five when this
is ``None`` or zero; other modes do not serialize this field.
timeout_ms: Optional timeout in milliseconds. ``None`` and zero are
omitted from the IR.
"""

agent_uri: str
mode: str
Expand All @@ -16,7 +30,17 @@ class AgentToolDef:
timeout_ms: int | None = None

def to_ir_kind(self) -> dict[str, Any]:
"""Compile to IR node kind definition."""
"""Serialize the configuration as an agent-tool node-kind fragment.

The target is encoded as ``{"auto": True}`` or ``{"explicit": uri}``.
Conversational mode becomes a mapping containing its turn limit; sync
and streaming modes remain strings. No agent is invoked or limits
validated by this method.

Returns:
A dictionary containing the node type, target, mode, description,
and any nonempty budget or nonzero timeout.
"""
agent_target: dict[str, Any] = {"auto": True} if self.agent_uri == "auto" else {"explicit": self.agent_uri}
ir: dict[str, Any] = {
"type": "agent_tool",
Expand All @@ -41,15 +65,32 @@ def agent_tool(
max_turns: int | None = None,
timeout_ms: int | None = None,
) -> AgentToolDef:
"""Wrap an agent as a callable tool.
"""Create an agent-tool definition for later IR serialization.

This stores configuration without calling an agent or validating the URI,
invocation mode, or limits.

Args:
agent: Agent URI (e.g., "jamjet://org/classifier") or "auto" for coordinator routing.
agent: Agent URI, such as ``"jamjet://org/classifier"``, or ``"auto"``
for an automatic target.
description: Human-readable description of what this tool does.
mode: Invocation mode — "sync", "streaming", or "conversational".
budget: Budget constraints (max_cost_usd, max_tokens).
max_turns: Max turns for conversational mode.
timeout_ms: Timeout in milliseconds.
mode: Invocation mode: ``"sync"`` (default), ``"streaming"``, or
``"conversational"``.
budget: Optional limits with keys ``max_cost_usd`` and ``max_tokens``.
An empty mapping is omitted during serialization.
max_turns: Turn limit for conversational mode only. ``None`` or zero
serializes as five turns.
timeout_ms: Optional timeout in milliseconds. ``None`` or zero is
omitted during serialization.

Returns:
An ``AgentToolDef`` whose ``to_ir_kind()`` method produces a node-kind
dictionary. The definition itself is not a callable agent.

Examples:
>>> tool = agent_tool("jamjet://org/classifier", "Classifies text")
>>> tool.to_ir_kind()["agent"]
{'explicit': 'jamjet://org/classifier'}
"""
return AgentToolDef(
agent_uri=agent,
Expand Down
72 changes: 70 additions & 2 deletions sdk/python/jamjet/coordinator/default_strategy.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"""Registry filtering and heuristic scoring with an optional LLM tiebreaker."""

from __future__ import annotations

import json
Expand All @@ -18,9 +20,15 @@


class DefaultCoordinatorStrategy(CoordinatorStrategy):
"""Built-in coordinator strategy: structured scoring with optional LLM tiebreaker."""
"""Route using advertised agent metadata and optional model-based selection."""

def __init__(self, registry: Any = None) -> None:
"""Initialize the strategy with an optional agent registry.

Args:
registry: Object exposing an async list_agents() method that returns
agent mappings. Discovery returns no candidates when omitted.
"""
self._registry = registry

async def discover(
Expand All @@ -31,6 +39,19 @@ async def discover(
trust_domain: str | None,
context: dict[str, Any],
) -> tuple[list[AgentCandidate], list[dict[str, str]]]:
"""Filter registry agents by required skills and trust domain.

Args:
task: Task description; unused by this implementation.
required_skills: Skills every returned agent must advertise.
preferred_skills: Optional skills; unused by this implementation.
trust_domain: Domain agents must match when this value is nonempty.
context: Additional inputs; unused by this implementation.

Returns:
Eligible candidates and excluded agents with URI and reason mappings.
Both lists are empty when no registry is configured.
"""
if self._registry is None:
return [], []

Expand Down Expand Up @@ -80,6 +101,21 @@ async def score(
weights: dict[str, float],
context: dict[str, Any],
) -> tuple[list[ScoringResult], float]:
"""Rank candidates using skill counts and advertised metadata.

Args:
task: Task description passed to the capability scoring hook. The
default hook does not inspect its text.
candidates: Agents to score.
weights: Dimension weights passed to DimensionScores.composite().
An empty mapping selects equal weights.
context: May contain preferred_reasoning_modes, a list of mode names
used to add a capability bonus for matching candidates.

Returns:
Rankings sorted by descending composite score and the difference
between the first two scores, or 1.0 with fewer than two candidates.
"""
results = []
for c in candidates:
reasoning_bonus = self._score_reasoning_modes(c, context)
Expand Down Expand Up @@ -111,6 +147,21 @@ async def decide(
tiebreaker_model: str,
context: dict[str, Any],
) -> Decision:
"""Select the top candidate, using a model when the top scores are close.

Args:
task: Task description supplied to the tiebreaker prompt.
top_candidates: Candidates in descending composite-score order.
threshold: Maximum difference between the top two scores that
enables a tiebreaker when a model is configured.
tiebreaker_model: Model identifier, or an empty string to disable it.
context: May contain agent_card_summaries, a mapping from agent URI
to a description included in the tiebreaker prompt.

Returns:
A decision with no selection for an empty list, the top-ranked agent
for structured selection, or the tiebreaker's result.
"""
if not top_candidates:
return Decision(selected_uri=None, method="no_candidates", confidence=0.0)

Expand All @@ -137,7 +188,20 @@ async def _llm_tiebreaker(
context: dict[str, Any],
max_candidates: int = 3,
) -> Decision:
"""Call an LLM to break a tie between closely-scored candidates."""
"""Ask a model to select among the leading candidates.

Args:
task: Task description included in the prompt.
candidates: Nonempty rankings in descending composite-score order.
model: Model identifier passed to call_llm().
context: Optional agent_card_summaries keyed by agent URI.
max_candidates: Maximum number of leading candidates shown.

Returns:
A model-backed decision with token counts. An invalid selected URI
falls back to the first candidate. A failed call or response parsing
returns a tiebreaker_failed decision selecting that same candidate.
"""
tied = candidates[:max_candidates]

# Build prompt with Agent Card summaries
Expand Down Expand Up @@ -207,19 +271,23 @@ async def _llm_tiebreaker(
)

def _score_capability(self, task: str, candidate: AgentCandidate) -> float:
"""Return 0.3 without skills, otherwise skill count / 5 capped at 1.0."""
if not candidate.skills:
return 0.3
return min(len(candidate.skills) / 5.0, 1.0)

def _score_cost(self, candidate: AgentCandidate) -> float:
"""Map low, medium, and high cost to scores; unknown cost scores 0.5."""
mapping = {"low": 1.0, "medium": 0.7, "high": 0.4}
return mapping.get(candidate.cost_class or "", 0.5)

def _score_latency(self, candidate: AgentCandidate) -> float:
"""Map low, medium, and high latency to scores; unknown latency scores 0.5."""
mapping = {"low": 1.0, "medium": 0.7, "high": 0.4}
return mapping.get(candidate.latency_class or "", 0.5)

def _score_reasoning_modes(self, candidate: AgentCandidate, context: dict[str, Any]) -> float:
"""Add up to 0.2 for overlap with context's preferred_reasoning_modes."""
preferred = context.get("preferred_reasoning_modes", [])
if not preferred or not candidate.reasoning_modes:
return 0.0
Expand Down
Loading