diff --git a/coact/__init__.py b/coact/__init__.py index c77471d..dad0ad3 100644 --- a/coact/__init__.py +++ b/coact/__init__.py @@ -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() @@ -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", diff --git a/coact/complete.py b/coact/complete.py index 9e6c320..7283865 100644 --- a/coact/complete.py +++ b/coact/complete.py @@ -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') @@ -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")) @@ -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). @@ -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 # --------------------------------------------------------------------------- diff --git a/coact/llm.py b/coact/llm.py new file mode 100644 index 0000000..0962320 --- /dev/null +++ b/coact/llm.py @@ -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 diff --git a/coact/realize.py b/coact/realize.py index 897b1d4..cd2f69b 100644 --- a/coact/realize.py +++ b/coact/realize.py @@ -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 @@ -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) diff --git a/coact/synthesis.py b/coact/synthesis.py index b44953c..6f8536b 100644 --- a/coact/synthesis.py +++ b/coact/synthesis.py @@ -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') @@ -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}", @@ -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 diff --git a/tests/test_synthesis_mcp.py b/tests/test_synthesis_mcp.py new file mode 100644 index 0000000..370064f --- /dev/null +++ b/tests/test_synthesis_mcp.py @@ -0,0 +1,138 @@ +"""Tests for the LLM facade, LLM-assisted persona synthesis, and the mcp backend.""" + +import importlib.util + +import pytest +from skill.base import Skill, SkillMeta + +from coact import ( + complete, + realize, + resolve_llm, + structured, + synthesize_persona, +) +from coact.base import ReturnContract + +_HAS_PY2MCP = importlib.util.find_spec("py2mcp") is not None + + +def _skill(name="ux", description="Analyze UX bundles.", body="steps"): + return Skill(meta=SkillMeta(name=name, description=description), body=body) + + +# --------------------------------------------------------------------------- +# llm facade +# --------------------------------------------------------------------------- + + +def test_resolve_llm_passthrough_callable(): + fn = resolve_llm(lambda p: "echo:" + p) + assert fn("hi") == "echo:hi" + + +def test_resolve_llm_aw_stepconfig_shape(): + class FakeStepConfig: + def resolve_llm(self): + return lambda p: "cfg:" + p + + fn = resolve_llm(FakeStepConfig()) + assert fn("x") == "cfg:x" + + +def test_resolve_llm_none_degrades_gracefully(): + # With no provider configured/injected, returns either None or a callable; + # crucially it does not raise. + result = resolve_llm(None) + assert result is None or callable(result) + + +def test_structured_with_injected_llm(): + fn = lambda p: '```json\n{"score": 7}\n```' + out = structured("rate it", {"type": "object"}, llm=fn) + assert out == {"score": 7} + + +def test_structured_returns_none_without_llm(): + # an llm that yields non-JSON, retried, still fails -> None + out = structured("x", {"type": "object"}, llm=lambda p: "not json at all", retries=1) + assert out is None + + +# --------------------------------------------------------------------------- +# LLM-assisted persona (optional; template otherwise) +# --------------------------------------------------------------------------- + + +def test_persona_template_without_llm(): + persona, src = synthesize_persona( + _skill(), return_contract=ReturnContract(json_schema={"type": "object"}) + ) + assert src == "synthesized-template" + assert "You are the **ux** agent" in persona + + +def test_persona_llm_drafts_identity_but_keeps_contract_deterministic(): + drafted = "You are a sharp ux agent. Follow the `ux` skill as your source of truth." + persona, src = synthesize_persona( + _skill(), + return_contract=ReturnContract(json_schema={"type": "object"}, description="d"), + tools=["Read"], + llm=lambda prompt: drafted, + ) + assert src == "synthesized-llm" + assert drafted in persona + # invariants + return contract still appended deterministically + assert "Operating invariants" in persona + assert "Return contract" in persona + + +def test_complete_threads_llm_through(): + drafted = "You are the ux agent, grounded in the `ux` skill." + ad = complete(_skill(), llm=lambda p: drafted) + assert drafted in ad.prompt + + +def test_complete_llm_failure_falls_back_to_template(): + def broken_llm(prompt): + raise RuntimeError("provider down") + + ad = complete(_skill(), llm=broken_llm) + assert "You are the **ux** agent" in ad.prompt # template fallback + + +# --------------------------------------------------------------------------- +# mcp backend (via py2mcp) +# --------------------------------------------------------------------------- + + +@pytest.mark.skipif(not _HAS_PY2MCP, reason="py2mcp not installed") +def test_mcp_backend_exposes_declared_tools(tmp_path): + skill_dir = tmp_path / "pather" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text( + """--- +name: pather +description: Path helpers exposed as tools. +coact: + mcp: + - module: os.path + functions: [basename, dirname] +--- +# pather +body +""" + ) + server = realize(skill_dir, backend="mcp") + assert server.name == "pather-tools" + import asyncio + + tool = asyncio.run(server.get_tool("basename")) + assert tool.name == "basename" + + +@pytest.mark.skipif(not _HAS_PY2MCP, reason="py2mcp not installed") +def test_mcp_backend_errors_without_declared_tools(): + s = _skill() # in-memory skill, no coact: mcp block + with pytest.raises(ValueError, match="No Python tools to expose"): + realize(s, backend="mcp")