File size: 8,884 Bytes
116524e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
"""Agentic SkillManager — mutates the skillbook directly via tool calls.



The SkillManager runs as a :class:`RecursiveAgent` with atomic mutation

tools (``add_skill``, ``update_skill``, ``remove_skill``, ``tag_skill``)

and read-only inspection tools (``search_skills``, ``read_skill``). Each

tool applies its effect to the real :class:`Skillbook` immediately.



The ``SkillManagerOutput`` returned by :meth:`SkillManager.update_skills`

is a post-hoc **audit log**: the ``reasoning`` comes from the agent's

structured output, and ``operations`` is the sequence of mutations the

tools recorded during the run. ``UpdateStep`` is the sole invocation

point; there is no downstream ``ApplyStep`` — the skillbook has already

been mutated when ``update_skills`` returns.

"""

from __future__ import annotations

import json
import logging
from typing import Any, Optional

from pydantic import BaseModel, ConfigDict, Field
from pydantic_ai.models import Model as PydanticModel
from pydantic_ai.settings import ModelSettings

from ..core.insight_source import InsightSource
from ..core.outputs import SkillManagerOutput
from ..core.recursive_agent import AgenticConfig, BudgetExhausted, RecursiveAgent
from ..core.skillbook import Skillbook, UpdateBatch
from .prompts import SKILL_MANAGER_PROMPT, SKILL_MANAGER_SYSTEM
from .sm_tools import (
    SMDeps,
    register_add_skill,
    register_remove_skill,
    register_sm_read_skill,
    register_sm_search_skills,
    register_tag_skill,
    register_update_skill,
)

logger = logging.getLogger(__name__)


class SkillManagerReport(BaseModel):
    """Structured output the SkillManager emits when it finishes.



    Only ``reasoning`` is produced by the LLM. The audit trail of

    executed mutations is collected by the tools on ``SMDeps.operations``

    and spliced into the final :class:`SkillManagerOutput` by

    :meth:`SkillManager.update_skills`.

    """

    model_config = ConfigDict(arbitrary_types_allowed=True)

    reasoning: str = Field(..., description="Summary of the actions you took and why.")


class SkillManager(RecursiveAgent):
    """Transforms reflections into skillbook mutations via atomic tool calls.



    Subclass of :class:`RecursiveAgent` — inherits compaction, recursion,

    and budget management.



    The SkillManager is the third ACE role. Tools mutate the real

    :class:`Skillbook` directly: there is no staging, and no

    ``ApplyStep`` follows ``UpdateStep``. The returned

    :class:`SkillManagerOutput` is an audit log of what the tools

    already executed.



    Args:

        model: Model identifier string (LiteLLM / PydanticAI) or a

            pre-built ``Model`` instance.

        config: ``AgenticConfig`` — controls request/token budget,

            compaction, recursion depth. ``max_requests=1`` approximates

            the old one-shot behavior (a single tool turn).

        prompt_template: User prompt template (defaults to

            :data:`SKILL_MANAGER_PROMPT`).

        system_prompt: System prompt (defaults to

            :data:`SKILL_MANAGER_SYSTEM`).

        model_settings: Optional PydanticAI ``ModelSettings``.



    Example::



        sm = SkillManager("gpt-4o-mini", config=AgenticConfig(max_requests=20))

        output = sm.update_skills(

            reflections=(reflection_output,),

            skillbook=skillbook,         # real Skillbook, not a view

            question_context="Math problem solving",

            progress="5/10 correct",

            injected_skill_ids=ctx.injected_skill_ids,

        )

        # skillbook has already been mutated; output is the audit log

    """

    def __init__(

        self,

        model: str | PydanticModel,

        *,

        config: Optional[AgenticConfig] = None,

        prompt_template: str = SKILL_MANAGER_PROMPT,

        system_prompt: str = SKILL_MANAGER_SYSTEM,

        model_settings: ModelSettings | None = None,

    ) -> None:
        self._prompt_template = prompt_template

        if model_settings is None:
            from pydantic_ai.models.bedrock import BedrockModelSettings

            model_settings = BedrockModelSettings(
                bedrock_cache_instructions=True,
                bedrock_cache_tool_definitions=True,
                bedrock_cache_messages=True,
            )

        super().__init__(
            model,
            output_type=SkillManagerReport,
            system_prompt=system_prompt,
            config=config or AgenticConfig(),
            model_settings=model_settings,
            tools=[
                register_sm_search_skills,
                register_sm_read_skill,
                register_add_skill,
                register_update_skill,
                register_remove_skill,
                register_tag_skill,
            ],
            tool_names_to_compact=(
                "search_skills",
                "read_skill",
            ),
            span_label="sm",
        )

    def update_skills(

        self,

        *,

        reflections: tuple,

        skillbook: Skillbook,

        question_context: str,

        progress: str,

        source: InsightSource | None = None,

        injected_skill_ids: tuple[str, ...] = (),

        **kwargs: Any,

    ) -> SkillManagerOutput:
        """Run the agent; tools mutate ``skillbook`` as they fire.



        This method signature matches :class:`SkillManagerLike`.



        Args:

            reflections: Tuple of Reflector analyses (1-tuple for single,

                N-tuple for batch).

            skillbook: Real :class:`Skillbook` — mutated in place by the

                agent's tools.

            question_context: Description of the task domain.

            progress: Current progress summary (e.g. ``"5/10 correct"``).

            source: Base provenance record for the current learning trace.

                If ``None``, mutations are recorded without provenance.

            injected_skill_ids: Skills rendered into the Agent's prompt

                this run — the tagging scope surfaced to the agent.

            **kwargs: Accepted for protocol compatibility but not

                forwarded.



        Returns:

            :class:`SkillManagerOutput` audit log. The mutations are

            already applied; the caller does NOT need to call

            ``skillbook.apply_update()``.

        """
        reflections_data = [
            {
                "reasoning": r.reasoning,
                "error_identification": r.error_identification,
                "root_cause_analysis": r.root_cause_analysis,
                "correct_approach": r.correct_approach,
                "key_insight": r.key_insight,
            }
            for r in reflections
        ]

        prompt = self._prompt_template.format(
            progress=progress,
            stats=json.dumps(skillbook.stats()),
            injected_skill_ids=(
                json.dumps(list(injected_skill_ids)) if injected_skill_ids else "[]"
            ),
            reflections=json.dumps(reflections_data, ensure_ascii=False, indent=2),
            question_context=question_context,
        )

        deps = SMDeps(
            config=self.config,
            depth=0,
            max_depth=self.config.max_depth,
            skillbook=skillbook,
            current_source=source,
        )

        from pydantic_ai.messages import CachePoint

        prompt_payload: Any = [prompt, CachePoint(ttl="5m")]

        try:
            report, metadata = self.run(deps=deps, prompt=prompt_payload)
            reasoning = report.reasoning if report is not None else ""
            raw = {
                **metadata,
                "sm_trace": {
                    "total_iterations": deps.iteration,
                    "compactions": metadata.get("compactions", 0),
                },
            }
        except BudgetExhausted as exc:
            logger.warning(
                "SkillManager budget exhausted after %d compactions; returning partial audit",
                exc.compaction_count,
            )
            reasoning = "SkillManager budget exhausted before completing."
            raw = {
                "timeout": True,
                "sm_trace": {
                    "total_iterations": deps.iteration,
                    "compactions": exc.compaction_count,
                },
            }
        except Exception as e:
            logger.error("SkillManager failed: %s", e, exc_info=True)
            reasoning = f"SkillManager failed: {e}"
            raw = {"error": str(e)}

        return SkillManagerOutput(
            update=UpdateBatch(reasoning=reasoning, operations=list(deps.operations)),
            raw=raw,
        )