File size: 6,707 Bytes
ebd50f7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Advisory function-call tools β€” PATH 1 of the agent harness.

This module holds the five advisory ``function_call`` actions (CVE lookup, risk
scoring, and drafting), exposed to the model as native Pydantic-AI tools
(``@agent.tool``). It is split out of :mod:`agents.soc_agent` so that file stays
focused on the loop, the brain, and the builders.

Each tool is a thin, typed wrapper around :func:`_run_action`, which fixes the
backend + action name and routes through the harness's shared governance helper
(:func:`agents.soc_agent.govern_action`) β€” the *same* checkpoint the structured-
draft loop uses, so an advisory tool call is governed exactly like any other
action. Typed tool parameters give the model a precise argument schema, which is
what makes native tool-calling reliable here.

Note the import direction: this module depends on :mod:`agents.soc_agent` for the
shared governance primitives, and ``soc_agent.build_agent`` imports
:func:`register_function_tools` lazily (inside the function) to register them. That
keeps the dependency one-way at import time, so there is no circular import.
"""

from __future__ import annotations

from typing import Any

from pydantic_ai import Agent, RunContext

from agents.soc_agent import GovernanceContext, _action_signature, govern_action
from control_plane.schema import Backend, ProposedAction


#: The advisory action names exposed as native tools. Kept as data so a test (and
#: a reader) can see the exact tool surface at a glance.
FUNCTION_CALL_TOOLS: tuple[str, ...] = (
    "cve_lookup",
    "risk_score",
    "draft_remediation",
    "draft_incident",
    "recommend_containment",
)


def _run_action(
    deps: GovernanceContext, action_name: str, arguments: dict[str, Any]
) -> dict[str, Any]:
    """Govern one advisory action and return the outcome to the model as a dict.

    The five tools below are thin typed fronts for this function. Each fixes
    ``backend = FUNCTION_CALL`` and its own ``action_name`` (the model can't forge
    them); the model supplies only the business arguments. Returns a small JSON
    object the model reads as feedback β€” never an exception.
    """
    arguments = {k: v for k, v in arguments.items() if v is not None}

    action = ProposedAction(
        incident_id=deps.incident_id,
        agent_id=deps.agent_id,
        # The model no longer chooses a tier; we record the operator ceiling the
        # gate evaluates against. The verdict stands on that ceiling alone.
        autonomy_tier=deps.operator_tier,
        backend=Backend.FUNCTION_CALL,
        action_name=action_name,
        arguments=arguments,
        reason=f"Agent requested {action_name} for incident {deps.incident_id}",
    )

    # Repeat nudge: a live model can loop on the same advisory call β€” re-running a
    # successful one, OR (just as wasteful) re-trying one that was already denied β€”
    # burning its step budget without making progress. If this exact action+args was
    # already attempted in this run, short-circuit with a firm "don't repeat" note
    # instead of governing a duplicate, steering the model to move on or conclude.
    signature = _action_signature(action)
    for prior in deps.turns:
        if _action_signature(prior.action) != signature:
            continue
        if prior.executed:
            # Already succeeded β€” hand back the same result, tell it not to repeat.
            return {
                "governance": "ALLOWED",
                "note": (
                    f"You already ran {action_name} with these arguments earlier in "
                    "this run; the result is unchanged. Do NOT call it again β€” use "
                    "the earlier result, take a different step, or give your FINAL "
                    "ANSWER."
                ),
                "result": prior.result,
            }
        # Already attempted and NOT allowed β€” repeating it will be denied again.
        return {
            "governance": prior.decision.value,
            "note": (
                f"You already tried {action_name} with these arguments and it was "
                f"not allowed ({prior.decision.value}). Do NOT repeat it β€” take a "
                "different, allowed step or give your FINAL ANSWER."
            ),
            "reason": prior.reason,
        }

    turn = govern_action(deps, action)
    if turn.executed:
        return {"governance": "ALLOWED", "result": turn.result}
    if turn.execution_error is not None:
        return {"governance": "ALLOWED", "executed": False, "error": turn.execution_error}
    return {"governance": turn.decision.value, "reason": turn.reason}


def register_function_tools(agent: Agent[GovernanceContext, Any]) -> None:
    """Register the five advisory `function_call` tools on *agent* (once, at build).

    Each tool is a thin, typed wrapper: the model sees a clear signature (e.g.
    ``cve_lookup(cve_id: str)``) and fills the arguments, while the body fixes the
    backend + action name and hands off to the shared governance helper. The typed
    parameters are deliberate β€” they give the model a precise argument schema,
    which is what makes native tool-calling reliable here.
    """

    @agent.tool
    def cve_lookup(ctx: RunContext[GovernanceContext], cve_id: str) -> dict[str, Any]:
        """Look up a CVE record (advisory, read-only)."""
        return _run_action(ctx.deps, "cve_lookup", {"cve_id": cve_id})

    @agent.tool
    def risk_score(
        ctx: RunContext[GovernanceContext], cve_id: str, asset_id: str | None = None
    ) -> dict[str, Any]:
        """Score a CVE's risk, optionally weighted by an asset's criticality (advisory)."""
        return _run_action(ctx.deps, "risk_score", {"cve_id": cve_id, "asset_id": asset_id})

    @agent.tool
    def draft_remediation(ctx: RunContext[GovernanceContext], cve_id: str) -> dict[str, Any]:
        """Draft remediation guidance for a CVE β€” text only, nothing is applied."""
        return _run_action(ctx.deps, "draft_remediation", {"cve_id": cve_id})

    @agent.tool
    def draft_incident(ctx: RunContext[GovernanceContext]) -> dict[str, Any]:
        """Draft an incident summary from this incident's alerts β€” text only."""
        return _run_action(ctx.deps, "draft_incident", {"incident_id": ctx.deps.incident_id})

    @agent.tool
    def recommend_containment(
        ctx: RunContext[GovernanceContext], asset_id: str, container_id: str | None = None
    ) -> dict[str, Any]:
        """Recommend containment steps for an asset β€” advisory only, nothing is done."""
        return _run_action(
            ctx.deps, "recommend_containment", {"asset_id": asset_id, "container_id": container_id}
        )