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
9 changes: 9 additions & 0 deletions coact/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,16 +38,19 @@
register_validator,
validate_coact_block,
)
from coact.llm import resolve_llm, structured
from coact.policy import CompletionPolicy, default_policy
from coact.realize import (
RealizedHost,
RunnableAgent,
realize,
realize_host,
realize_mcp,
realize_sdk,
)
from coact.realize import backends as realization_backends
from coact.stores import AgentStore, agents_dir
from coact.synthesis import synthesize_persona, synthesize_return_contract

# Make `skill validate` aware of the coact: block as soon as coact is imported.
register_validator()
Expand All @@ -69,9 +72,15 @@
"realize",
"realize_host",
"realize_sdk",
"realize_mcp",
"RealizedHost",
"RunnableAgent",
"realization_backends",
# Synthesis & LLM facade
"synthesize_persona",
"synthesize_return_contract",
"resolve_llm",
"structured",
# Emit
"emit_agent",
"emitters",
Expand Down
17 changes: 13 additions & 4 deletions coact/complete.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,17 @@


def plan_completion(
source: SkillSource, *, policy: Optional[CompletionPolicy] = None
source: SkillSource,
*,
policy: Optional[CompletionPolicy] = None,
llm: object = None,
) -> AgentPlan:
"""Plan the skill→agent completion, recording the provenance of every field.

Accepts a :class:`~skill.base.Skill`, a path to a skill directory / SKILL.md,
or a skill key/name resolvable in the local store or project skills.
or a skill key/name resolvable in the local store or project skills. Pass an
optional ``llm`` (any ``callable(str)->str``, an ``aw`` ``StepConfig``, or a
model name) to *draft* a richer persona — the mechanical path needs none.

>>> from skill.base import Skill, SkillMeta
>>> s = Skill(meta=SkillMeta(name='auditor', description='Audit a bundle for issues.'), body='steps')
Expand Down Expand Up @@ -129,6 +134,7 @@ def plan_completion(
return_contract=return_contract,
tools=tools,
extra_skills=[s for s in skills if s != name],
llm=llm,
)
prov.append(FieldProvenance("prompt", persona, persona_src, "system prompt / persona"))

Expand Down Expand Up @@ -162,7 +168,10 @@ def plan_completion(


def complete(
source: SkillSource, *, policy: Optional[CompletionPolicy] = None
source: SkillSource,
*,
policy: Optional[CompletionPolicy] = None,
llm: object = None,
) -> AgentDefinition:
"""Complete a skill into an :class:`AgentDefinition` (the plan's agent).

Expand All @@ -172,7 +181,7 @@ def complete(
>>> ad.name, ad.skills, ('Return contract' in ad.prompt)
('ux', ['ux'], True)
"""
return plan_completion(source, policy=policy).agent
return plan_completion(source, policy=policy, llm=llm).agent


# ---------------------------------------------------------------------------
Expand Down
119 changes: 119 additions & 0 deletions coact/llm.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
"""Provider-agnostic LLM facade — thin, injected, never a hard dependency.

COACT_SPEC §5.5 / §8 / DECISIONS D10. Any LLM use in coact goes through this
facade, and **every mechanical path runs with no LLM at all**. The facade
resolves an injected ``llm`` to a ``callable(str) -> str`` from, in order:

1. an explicit ``callable`` (use as-is);
2. an ``aw`` ``StepConfig`` (use its ``resolve_llm()`` — reuse aw's injection);
3. a model-name ``str`` (wrap ``skill.ai.chat`` with that model);
4. ``None`` → ``skill.ai.chat`` if any provider is configured, else ``None``.

When nothing is available, :func:`resolve_llm` returns ``None`` and callers fall
back to their template path — no provider lock-in, no crash. :func:`structured`
adds a best-effort ``(prompt, schema) -> dict`` on top (instruct-JSON + parse +
one retry), used only for *optional* return-contract drafting.
"""

from __future__ import annotations

import json
import re
from typing import Any, Callable, Optional

LLMCallable = Callable[[str], str]


def resolve_llm(llm: Any = None) -> Optional[LLMCallable]:
"""Resolve ``llm`` to a ``callable(str) -> str``, or ``None`` if unavailable.

>>> resolve_llm(lambda p: 'hi')('x')
'hi'
>>> resolve_llm('no-such-thing') is None or callable(resolve_llm('no-such-thing'))
True
"""
if llm is None:
return _skill_ai_llm()
if callable(llm):
return llm
# aw StepConfig (or anything exposing resolve_llm)
resolve = getattr(llm, "resolve_llm", None)
if callable(resolve):
try:
resolved = resolve()
if callable(resolved):
return resolved
except Exception:
return None
if isinstance(llm, str):
return _skill_ai_llm(model=llm)
return None


def _skill_ai_llm(*, model: Optional[str] = None) -> Optional[LLMCallable]:
"""A ``callable(str) -> str`` backed by ``skill.ai.chat``, or ``None`` if no provider."""
try:
from skill import ai
except Exception:
return None
try:
if not ai.is_ai_available():
return None
except Exception:
return None

def _chat(prompt: str) -> str:
return ai.chat(prompt, model=model)

return _chat


def structured(
prompt: str,
schema: dict,
*,
llm: Any = None,
retries: int = 1,
) -> Optional[dict]:
"""Best-effort schema-conforming dict from an LLM, or ``None`` if unavailable.

Native structured output is provider-specific; this facade stays portable by
instructing JSON-only output that conforms to ``schema`` and parsing it,
retrying once on a parse miss. Returns ``None`` when no LLM is resolvable —
callers fall back to a template (DECISIONS D10).
"""
fn = resolve_llm(llm)
if fn is None:
return None
instruction = (
f"{prompt}\n\nReturn ONLY a JSON object conforming to this JSON Schema "
f"(no prose, no code fences):\n{json.dumps(schema, indent=2)}"
)
last_text = ""
for _ in range(retries + 1):
last_text = fn(instruction)
parsed = _extract_json(last_text)
if isinstance(parsed, dict):
return parsed
instruction = (
f"{prompt}\n\nYour previous reply was not valid JSON. Return ONLY a "
f"JSON object for this schema:\n{json.dumps(schema)}"
)
return None


def _extract_json(text: str) -> Any:
"""Pull a JSON object out of an LLM reply (tolerating code fences/prose)."""
text = text.strip()
fence = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.DOTALL)
candidate = fence.group(1) if fence else text
try:
return json.loads(candidate)
except (ValueError, TypeError):
brace = re.search(r"\{.*\}", candidate, re.DOTALL)
if brace:
try:
return json.loads(brace.group(0))
except (ValueError, TypeError):
return None
return None
68 changes: 68 additions & 0 deletions coact/realize.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
from coact.base import AgentDefinition
from coact.complete import _resolve_skill, complete
from coact.emit import emit_agent, from_claude_agent_md
from coact.frontmatter import parse_coact_meta
from coact.policy import CompletionPolicy
from coact.stores import agents_dir
from coact.util import check_requirements
Expand Down Expand Up @@ -351,3 +352,70 @@ def realize_sdk(


backends.register("sdk", realize_sdk)


# ---------------------------------------------------------------------------
# mcp backend — expose a skill's Python tools as an MCP server (via py2mcp)
# ---------------------------------------------------------------------------


def realize_mcp(
target: RealizeTarget,
*,
name: Optional[str] = None,
input_trans: Optional[Callable[[dict], dict]] = None,
) -> Any:
"""Expose a skill's declared Python tools as a FastMCP server (foreign-host).

Reads the ``coact: mcp:`` block (``module`` + ``functions``) of the source
skill(s) and delegates to ``py2mcp.mk_mcp_from_refs`` — coact writes no MCP
plumbing (DECISIONS §6.1.3). ``target`` may be a skill source or an
:class:`AgentDefinition` (whose ``source_skill`` is resolved back to the
skill that carries the declaration).
"""
check_requirements(
{"py2mcp": "py2mcp", "fastmcp": "fastmcp"},
feature="realize(backend='mcp')",
)
from py2mcp import mk_mcp_from_refs

refs, server_name = _mcp_refs(target)
if not refs:
raise ValueError(
"No Python tools to expose via MCP. Declare them in a `coact: mcp:` "
"block (module + functions) on the source skill, or use "
"backend='host' / 'sdk'."
)
return mk_mcp_from_refs(refs, name=name or server_name, input_trans=input_trans)


def _mcp_refs(target: RealizeTarget) -> tuple[list[str], str]:
"""Collect ``'module:function'`` refs (and a server name) from coact: mcp blocks."""
skills = _resolve_skills_for_mcp(target)
refs: list[str] = []
names: list[str] = []
for sk in skills:
names.append(sk.meta.name)
for entry in parse_coact_meta(sk).mcp:
module = entry.get("module")
if not module:
continue
for fn in entry.get("functions") or []:
refs.append(f"{module}:{fn}")
server_name = (names[0] if len(names) == 1 else "coact") + "-tools"
return refs, server_name


def _resolve_skills_for_mcp(target: RealizeTarget) -> list[Skill]:
"""Resolve the skill(s) that carry the coact: mcp declaration for ``target``."""
if isinstance(target, (list, tuple)):
out: list[Skill] = []
for item in target:
out.extend(_resolve_skills_for_mcp(item))
return out
if isinstance(target, AgentDefinition):
return [_resolve_skill(target.source_skill or target.name)]
return [_resolve_skill(target)]


backends.register("mcp", realize_mcp)
80 changes: 66 additions & 14 deletions coact/synthesis.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,12 +97,17 @@ def synthesize_persona(
return_contract: ReturnContract,
tools: list[str] | None = None,
extra_skills: list[str] | None = None,
llm: object = None,
) -> tuple[str, ProvenanceSource]:
"""Synthesize the system prompt (persona) for an agent derived from ``skill``.

The template wraps the skill's intent as an identity, states operating
invariants, and appends the return contract — while **pointing at** the
source skill rather than inlining it (§3.3).
Wraps the skill's intent as an identity, states operating invariants, and
appends the return contract — while **pointing at** the source skill rather
than inlining it (§3.3). The identity paragraph is template-generated by
default; when an ``llm`` is resolvable it is *drafted* by the LLM instead
(richer, but the invariants and the return contract stay deterministic so the
machine-facing contract is never at the mercy of generation). The LLM is
optional — absent one, the template path is a sound default (DECISIONS D10).

>>> from skill.base import Skill, SkillMeta
>>> s = Skill(meta=SkillMeta(name='ux-analyst', description='Analyze UX bundles.'), body='steps')
Expand All @@ -112,23 +117,16 @@ def synthesize_persona(
True
"""
name = skill.meta.name
description = skill.meta.description.rstrip(".")
tool_note = (
f"Stay within your tool allowlist: {', '.join(tools)}."
if tools
else "Use only the tools you have been granted."
)
skills_clause = f"the `{name}` skill"
if extra_skills:
others = ", ".join(f"`{s}`" for s in extra_skills)
skills_clause = f"the `{name}` skill (and {others})"

identity, source = _persona_identity(skill, extra_skills=extra_skills, llm=llm)

sections = [
f"You are the **{name}** agent. {description}.",
"",
f"You have {skills_clause} loaded — follow its procedure exactly; do not "
"re-derive or duplicate it. The skill is the single source of truth for "
"*how* to do the work; you add identity, judgment, and a consumable result.",
identity,
"",
"## Operating invariants",
f"- {tool_note}",
Expand All @@ -139,4 +137,58 @@ def synthesize_persona(
"",
render_return_contract_section(return_contract),
]
return "\n".join(sections), "synthesized-template"
return "\n".join(sections), source


def _persona_identity(
skill: Skill, *, extra_skills: list[str] | None, llm: object = None
) -> tuple[str, ProvenanceSource]:
"""Build the identity paragraph — LLM-drafted when available, else template."""
name = skill.meta.name
description = skill.meta.description.rstrip(".")
skills_clause = f"the `{name}` skill"
if extra_skills:
others = ", ".join(f"`{s}`" for s in extra_skills)
skills_clause = f"the `{name}` skill (and {others})"

template = (
f"You are the **{name}** agent. {description}.\n\n"
f"You have {skills_clause} loaded — follow its procedure exactly; do not "
"re-derive or duplicate it. The skill is the single source of truth for "
"*how* to do the work; you add identity, judgment, and a consumable result."
)

drafted = _llm_draft_identity(skill, llm=llm)
if drafted:
return drafted, "synthesized-llm"
return template, "synthesized-template"


def _llm_draft_identity(skill: Skill, *, llm: object) -> str | None:
"""Draft a 2-3 sentence identity via an *explicitly injected* LLM, else None.

Drafting requires the caller to pass ``llm`` — coact never reaches for an
ambient provider on its own (the mechanical path stays offline, DECISIONS
D10). To use whatever provider is configured, pass ``llm=resolve_llm()``.
"""
if llm is None:
return None
from coact.llm import resolve_llm

fn = resolve_llm(llm)
if fn is None:
return None
prompt = (
"Write a concise 2-3 sentence system-prompt identity for a Claude subagent "
f"derived from a skill named '{skill.meta.name}' described as: "
f"\"{skill.meta.description}\".\n\n"
"Rules: speak in second person ('You are ...'); reference the skill by "
f"name (`{skill.meta.name}`) and instruct the agent to follow that skill's "
"procedure as the single source of truth; do NOT restate or invent the "
"procedure's steps; no markdown headers; identity prose only."
)
try:
text = fn(prompt).strip()
except Exception:
return None
return text or None
Loading
Loading