You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
221 lines
7.9 KiB
Python
221 lines
7.9 KiB
Python
# backend/app/agents/errand.py — 생활 심부름 에이전트 (plan→act→observe→reflect + 결재함 게이트)
|
|
import uuid
|
|
|
|
from sqlmodel import Session, select
|
|
|
|
from ..automation.event_bus import bus # phase-7 정본
|
|
from ..config import get_settings
|
|
from ..models import (
|
|
Approval,
|
|
AutonomySetting,
|
|
ErrandKind,
|
|
ErrandStatus,
|
|
ErrandStep,
|
|
ErrandTask,
|
|
StepState,
|
|
now,
|
|
)
|
|
from .guardrails import Budget, BudgetExceeded, external_effect_of
|
|
|
|
# 데모 결정성: scripted 플랜 (AGENT_PROVIDER=scripted 또는 tool 미가용 폴백)
|
|
# (phase, tool, label, detail, external_effect)
|
|
SCRIPTED = {
|
|
"cancel:Netflix": [
|
|
("plan", None, "해지 절차 계획 수립", "결제일·해지 경로·되돌리기 옵션 확인", False),
|
|
(
|
|
"act",
|
|
"web_search",
|
|
"Netflix 해지/일시정지 방법 확인",
|
|
"계정 설정 > 멤버십 일시정지 경로 확인",
|
|
False,
|
|
),
|
|
("observe", None, "최근 시청 기록 확인", "최근 2개월 시청 0건 — 일시정지 적합", False),
|
|
(
|
|
"act",
|
|
"http_fetch",
|
|
"해지 페이지 접속·세션 준비",
|
|
"로그인 세션 확보, 일시정지 폼 로드",
|
|
False,
|
|
),
|
|
("reflect", None, "결제 임박 확인", "모레 17,000원 결제 예정 — 그 전에 처리 필요", False),
|
|
(
|
|
"act",
|
|
"form_fill",
|
|
"일시정지 신청 제출",
|
|
"‘일시정지’ 버튼 제출 — 외부 영향(결제 변경)",
|
|
True,
|
|
),
|
|
],
|
|
"_default": [
|
|
("plan", None, "요청 계획 수립", "필요한 단계·외부 영향 여부 확인", False),
|
|
("act", "web_search", "처리 경로 확인", "관련 안내/절차 확인", False),
|
|
("observe", None, "상황 점검", "현재 상태 확인", False),
|
|
("act", "form_fill", "요청 제출", "외부 제출 — 확인 필요", True),
|
|
],
|
|
}
|
|
|
|
|
|
def _step(s: Session, errand_id: str, seq: int):
|
|
return s.exec(
|
|
select(ErrandStep).where(ErrandStep.errand_id == errand_id, ErrandStep.seq == seq)
|
|
).first()
|
|
|
|
|
|
def _blocked_step(s: Session, errand_id: str):
|
|
return s.exec(
|
|
select(ErrandStep).where(
|
|
ErrandStep.errand_id == errand_id, ErrandStep.state == StepState.blocked.value
|
|
)
|
|
).first()
|
|
|
|
|
|
class ErrandAgent:
|
|
def __init__(self, session: Session):
|
|
self.s = session
|
|
self.cfg = get_settings()
|
|
|
|
# ── 공개 진입 ──
|
|
def start(
|
|
self,
|
|
*,
|
|
kind: str,
|
|
title: str,
|
|
goal: str = "",
|
|
target: str | None = None,
|
|
tone: str = "amber",
|
|
) -> ErrandTask:
|
|
autonomy = self.s.get(AutonomySetting, "default")
|
|
level = autonomy.level if autonomy else "mixed"
|
|
er = ErrandTask(
|
|
id="er-" + uuid.uuid4().hex[:8],
|
|
kind=kind,
|
|
title=title,
|
|
goal=goal,
|
|
target=target,
|
|
tone=tone,
|
|
status=ErrandStatus.planning.value,
|
|
autonomy_at_start=level,
|
|
model=self._model_tag(),
|
|
)
|
|
self.s.add(er)
|
|
self.s.commit()
|
|
self._run_until_gate(er)
|
|
return er
|
|
|
|
def _run_until_gate(self, er: ErrandTask) -> None:
|
|
"""external_effect step 직전까지 진행. 만나면 결재함 게이트를 세우고 멈춘다."""
|
|
er.status = ErrandStatus.running.value
|
|
self.s.add(er)
|
|
self.s.commit()
|
|
budget = Budget()
|
|
try:
|
|
for seq, (phase, tool, label, detail, ext) in enumerate(self._plan(er)):
|
|
existing = _step(self.s, er.id, seq)
|
|
if existing and existing.state == StepState.done.value:
|
|
continue
|
|
budget.step()
|
|
step = existing or ErrandStep(
|
|
id=f"{er.id}-s{seq}",
|
|
errand_id=er.id,
|
|
seq=seq,
|
|
phase=phase,
|
|
tool=tool,
|
|
label=label,
|
|
detail=detail,
|
|
external_effect=ext,
|
|
)
|
|
if ext or external_effect_of(phase, tool, label):
|
|
step.external_effect = True
|
|
step.state = StepState.blocked.value
|
|
self.s.add(step)
|
|
self._raise_approval(er, step) # ← 안전 게이트(항상)
|
|
er.status = ErrandStatus.awaiting_approval.value
|
|
er.updated_at = now()
|
|
self.s.add(er)
|
|
self.s.commit()
|
|
return
|
|
step.state = StepState.running.value
|
|
self.s.add(step)
|
|
self.s.commit()
|
|
self._execute(er, step, budget)
|
|
step.state = StepState.done.value
|
|
self.s.add(step)
|
|
bus.publish("errand.updated", {"errand_id": er.id, "seq": seq, "external": False})
|
|
self.s.commit()
|
|
except BudgetExceeded as e:
|
|
er.status = ErrandStatus.failed.value
|
|
er.result_summary = f"자원 예산 초과: {e}"
|
|
er.updated_at = now()
|
|
self.s.add(er)
|
|
self.s.commit()
|
|
return
|
|
er.status = ErrandStatus.done.value
|
|
er.result_summary = self._summary(er)
|
|
er.updated_at = now()
|
|
self.s.add(er)
|
|
self.s.commit()
|
|
bus.publish("errand.completed", {"errand_id": er.id})
|
|
|
|
def _raise_approval(self, er: ErrandTask, step: ErrandStep) -> None:
|
|
"""external_effect → phase-7 Approval(risk=high, source=agent) 생성."""
|
|
is_cancel = er.kind == ErrandKind.cancel.value
|
|
ap = Approval(
|
|
id=f"ap-{er.id}",
|
|
icon="wallet",
|
|
tone=er.tone,
|
|
risk="high",
|
|
status="pending",
|
|
source="agent",
|
|
time="확인 필요",
|
|
title=f"{er.title}을(를) 추천해요" if is_cancel else er.title,
|
|
detail=step.detail or er.goal,
|
|
cta="일시정지" if is_cancel else "진행",
|
|
alt="유지" if is_cancel else "취소",
|
|
created_at=now(),
|
|
)
|
|
self.s.add(ap)
|
|
er.approval_id = ap.id
|
|
self.s.add(er)
|
|
self.s.commit()
|
|
bus.publish("approval.enqueued", {"id": ap.id, "risk": "high", "source": "agent"})
|
|
|
|
def resume_after_approval(self, er: ErrandTask) -> None:
|
|
"""결재함 승인 후 errand_runner 가 호출 — blocked step 실행 후 계속."""
|
|
blocked = _blocked_step(self.s, er.id)
|
|
if blocked:
|
|
blocked.state = StepState.running.value
|
|
self.s.add(blocked)
|
|
self.s.commit()
|
|
self._execute(er, blocked, Budget()) # 실제 외부 행동(승인됨)
|
|
blocked.state = StepState.done.value
|
|
self.s.add(blocked)
|
|
self.s.commit()
|
|
bus.publish(
|
|
"errand.updated", {"errand_id": er.id, "seq": blocked.seq, "external": True}
|
|
)
|
|
self._run_until_gate(er) # 다음 게이트 또는 완료
|
|
|
|
# ── 플랜/실행 ──
|
|
def _plan(self, er: ErrandTask):
|
|
key = f"{er.kind}:{er.target}"
|
|
return SCRIPTED.get(key, SCRIPTED["_default"])
|
|
|
|
def _execute(self, er: ErrandTask, step: ErrandStep, budget: Budget) -> None:
|
|
# scripted/폴백: 부작용 없는 observe 만. (실 tool 연동은 phase-15)
|
|
if step.tool:
|
|
budget.tool()
|
|
return
|
|
|
|
def _scripted(self) -> bool:
|
|
return True # phase-14: 결정적 scripted 플랜 고정(실 tool 은 phase-15)
|
|
|
|
def _model_tag(self) -> str:
|
|
return "scripted" if self._scripted() else f"agent:{self.cfg.ollama_model}"
|
|
|
|
def _summary(self, er: ErrandTask) -> str:
|
|
return (
|
|
"모레 결제 전 일시정지 완료(되돌리기 가능)"
|
|
if er.kind == ErrandKind.cancel.value
|
|
else "요청을 처리했어요"
|
|
)
|