repositories
loading repo index
repositories
loading repo index
repository
loading code, commits, and activity
Mirrored from https://github.com/yingqi-z20/Agent-libOS
stars
latest
clone command
git clone gitlawb://did:key:z6MkqRzA...RfoM/yingqi-z20-Agen...git clone gitlawb://did:key:z6MkqRzA.../yingqi-z20-Agen...d98dd2c9IPC1d ago| #1 | from __future__ import annotations |
| #2 | |
| #3 | import asyncio |
| #4 | import builtins |
| #5 | from typing import TYPE_CHECKING, Any |
| #6 | |
| #7 | from agent_libos.capability.manager import CapabilityManager |
| #8 | from agent_libos.config import DEFAULT_CONFIG, AgentLibOSConfig |
| #9 | from agent_libos.models import CapabilityRight, ProcessMessage, ProcessMessageKind |
| #10 | from agent_libos.models.exceptions import CapabilityDenied, HumanResponseRequired, NotFound, ValidationError |
| #11 | from agent_libos.utils.ids import new_id, utc_now |
| #12 | from agent_libos.models import ( |
| #13 | EventType, |
| #14 | HumanRequest, |
| #15 | HumanRequestStatus, |
| #16 | ProcessSignal, |
| #17 | ProcessStatus, |
| #18 | ) |
| #19 | from agent_libos.runtime.audit_manager import AuditManager |
| #20 | from agent_libos.runtime.event_bus import EventBus |
| #21 | from agent_libos.storage import SQLiteStore |
| #22 | from agent_libos.substrate import HumanProvider |
| #23 | |
| #24 | if TYPE_CHECKING: |
| #25 | from agent_libos.runtime.message_manager import ProcessMessageManager |
| #26 | |
| #27 | |
| #28 | class HumanObjectManager: |
| #29 | """HumanObject primitive: terminal queue, approvals, questions, and output.""" |
| #30 | |
| #31 | def __init__( |
| #32 | self, |
| #33 | store: SQLiteStore, |
| #34 | capabilities: CapabilityManager, |
| #35 | audit: AuditManager, |
| #36 | events: EventBus, |
| #37 | provider: HumanProvider, |
| #38 | config: AgentLibOSConfig | None = None, |
| #39 | ): |
| #40 | self.config = config or DEFAULT_CONFIG |
| #41 | self.store = store |
| #42 | self.capabilities = capabilities |
| #43 | self.audit = audit |
| #44 | self.events = events |
| #45 | self.provider = provider |
| #46 | self._messages: ProcessMessageManager | None = None |
| #47 | |
| #48 | def bind_messages(self, messages: "ProcessMessageManager") -> None: |
| #49 | self._messages = messages |
| #50 | |
| #51 | def query( |
| #52 | self, |
| #53 | pid: str, |
| #54 | human: str, |
| #55 | request: dict[str, Any], |
| #56 | blocking: bool = True, |
| #57 | ) -> str: |
| #58 | now = utc_now() |
| #59 | human_request = HumanRequest( |
| #60 | request_id=new_id("hreq"), |
| #61 | pid=pid, |
| #62 | human=human, |
| #63 | payload=request, |
| #64 | status=HumanRequestStatus.PENDING, |
| #65 | decision=None, |
| #66 | blocking=blocking, |
| #67 | created_at=now, |
| #68 | updated_at=now, |
| #69 | ) |
| #70 | self.store.insert_human_request(human_request) |
| #71 | if blocking: |
| #72 | # Blocking human requests suspend scheduling for this process until |
| #73 | # a terminal queue decision moves it back to RUNNABLE. |
| #74 | process = self.store.get_process(pid) |
| #75 | if process is not None: |
| #76 | process.status = ProcessStatus.WAITING_HUMAN |
| #77 | process.status_message = f"waiting for human request {human_request.request_id}" |
| #78 | process.updated_at = utc_now() |
| #79 | self.store.update_process(process) |
| #80 | self.events.emit( |
| #81 | EventType.HUMAN_QUERY, |
| #82 | source=pid, |
| #83 | target=f"human:{human}", |
| #84 | payload={"request_id": human_request.request_id, "request": request, "blocking": blocking}, |
| #85 | ) |
| #86 | self.audit.record( |
| #87 | actor=pid, |
| #88 | action="human.query", |
| #89 | target=f"human:{human}", |
| #90 | decision={"request_id": human_request.request_id, "blocking": blocking, "request": request}, |
| #91 | ) |
| #92 | return human_request.request_id |
| #93 | |
| #94 | def request_permission( |
| #95 | self, |
| #96 | pid: str, |
| #97 | human: str, |
| #98 | resource: str, |
| #99 | rights: list[str], |
| #100 | reason: str, |
| #101 | blocking: bool = True, |
| #102 | ) -> str: |
| #103 | return self.query( |
| #104 | pid=pid, |
| #105 | human=human, |
| #106 | request={ |
| #107 | "type": "permission_request", |
| #108 | "question": f"Set permission policy for {resource} rights={rights}: {reason}", |
| #109 | "requested_permission": { |
| #110 | "subject": pid, |
| #111 | "resource": resource, |
| #112 | "rights": rights, |
| #113 | }, |
| #114 | "context": {"reason": reason}, |
| #115 | }, |
| #116 | blocking=blocking, |
| #117 | ) |
| #118 | |
| #119 | def ask( |
| #120 | self, |
| #121 | pid: str, |
| #122 | question: str, |
| #123 | human: str | None = None, |
| #124 | context: dict[str, Any] | None = None, |
| #125 | blocking: bool = True, |
| #126 | ) -> str: |
| #127 | selected_human = human or self.config.runtime.default_human |
| #128 | resource = f"human:{selected_human}" |
| #129 | self.capabilities.require(pid, resource, CapabilityRight.WRITE) |
| #130 | return self.query( |
| #131 | pid=pid, |
| #132 | human=selected_human, |
| #133 | request={ |
| #134 | "type": "question", |
| #135 | "question": question, |
| #136 | "context": context or {}, |
| #137 | }, |
| #138 | blocking=blocking, |
| #139 | ) |
| #140 | |
| #141 | def answer_for_request(self, request_id: str) -> str: |
| #142 | request = self.get(request_id) |
| #143 | if request.payload.get("type") != "question": |
| #144 | raise ValidationError(f"human request is not a question: {request_id}") |
| #145 | if request.status == HumanRequestStatus.PENDING: |
| #146 | raise HumanResponseRequired( |
| #147 | request_id=request_id, |
| #148 | message=f"{request.pid} is waiting for human answer to {request_id}", |
| #149 | ) |
| #150 | if request.status != HumanRequestStatus.APPROVED: |
| #151 | raise CapabilityDenied(f"human question {request_id} was not answered: {request.status.value}") |
| #152 | decision = request.decision or {} |
| #153 | if "answer" not in decision: |
| #154 | raise ValidationError(f"human question {request_id} has no answer") |
| #155 | return str(decision["answer"]) |
| #156 | |
| #157 | def approve( |
| #158 | self, |
| #159 | request_id: str, |
| #160 | decision: dict[str, Any] | None = None, |
| #161 | responder: str | None = None, |
| #162 | ) -> HumanRequest: |
| #163 | return self._decide( |
| #164 | request_id, |
| #165 | HumanRequestStatus.APPROVED, |
| #166 | decision or {"approved": True}, |
| #167 | responder or self.config.runtime.default_human_actor, |
| #168 | ) |
| #169 | |
| #170 | def reject( |
| #171 | self, |
| #172 | request_id: str, |
| #173 | decision: dict[str, Any] | None = None, |
| #174 | responder: str | None = None, |
| #175 | ) -> HumanRequest: |
| #176 | return self._decide( |
| #177 | request_id, |
| #178 | HumanRequestStatus.REJECTED, |
| #179 | decision or {"approved": False}, |
| #180 | responder or self.config.runtime.default_human_actor, |
| #181 | ) |
| #182 | |
| #183 | def interrupt(self, pid: str, signal: ProcessSignal | str, payload: dict[str, Any] | None = None) -> str: |
| #184 | sig = ProcessSignal(signal) |
| #185 | process = self.store.get_process(pid) |
| #186 | if process is None: |
| #187 | raise NotFound(f"process not found: {pid}") |
| #188 | if sig == ProcessSignal.PAUSE: |
| #189 | process.status = ProcessStatus.PAUSED |
| #190 | elif sig == ProcessSignal.RESUME: |
| #191 | process.status = ProcessStatus.RUNNABLE |
| #192 | elif sig in {ProcessSignal.CANCEL, ProcessSignal.TERMINATE}: |
| #193 | process.status = ProcessStatus.KILLED |
| #194 | process.status_message = (payload or {}).get("reason") |
| #195 | process.updated_at = utc_now() |
| #196 | self.store.update_process(process) |
| #197 | event = self.events.emit( |
| #198 | EventType.PROCESS_SIGNAL, |
| #199 | source="human", |
| #200 | target=pid, |
| #201 | payload={"signal": sig.value, "payload": payload or {}}, |
| #202 | ) |
| #203 | self.audit.record( |
| #204 | actor="human", |
| #205 | action="human.interrupt", |
| #206 | target=f"process:{pid}", |
| #207 | decision={"signal": sig.value, "payload": payload or {}}, |
| #208 | ) |
| #209 | return event.event_id |
| #210 | |
| #211 | def send_process_message( |
| #212 | self, |
| #213 | recipient_pid: str, |
| #214 | body: str, |
| #215 | *, |
| #216 | kind: ProcessMessageKind | str = ProcessMessageKind.NORMAL, |
| #217 | human: str | None = None, |
| #218 | channel: str = "human", |
| #219 | correlation_id: str | None = None, |
| #220 | reply_to: str | None = None, |
| #221 | subject: str | None = None, |
| #222 | payload: dict[str, Any] | None = None, |
| #223 | ) -> ProcessMessage: |
| #224 | if self._messages is None: |
| #225 | raise RuntimeError("HumanObjectManager is not bound to a ProcessMessageManager") |
| #226 | selected_human = human or self.config.runtime.default_human |
| #227 | selected_kind = ProcessMessageKind(kind) |
| #228 | message_payload = dict(payload or {}) |
| #229 | message_payload.setdefault("source", "human_input") |
| #230 | message_payload.setdefault("human", selected_human) |
| #231 | message = self._messages.post( |
| #232 | sender=f"human:{selected_human}", |
| #233 | recipient_pid=recipient_pid, |
| #234 | kind=selected_kind, |
| #235 | channel=channel, |
| #236 | correlation_id=correlation_id, |
| #237 | reply_to=reply_to, |
| #238 | subject=subject if subject is not None else self._default_message_subject(selected_kind), |
| #239 | body=body, |
| #240 | payload=message_payload, |
| #241 | ) |
| #242 | self.audit.record( |
| #243 | actor=f"human:{selected_human}", |
| #244 | action="human.message", |
| #245 | target=f"process:{recipient_pid}", |
| #246 | decision={ |
| #247 | "message_id": message.message_id, |
| #248 | "kind": message.kind.value, |
| #249 | "channel": message.channel, |
| #250 | "correlation_id": message.correlation_id, |
| #251 | "reply_to": message.reply_to, |
| #252 | "subject": message.subject, |
| #253 | }, |
| #254 | ) |
| #255 | return message |
| #256 | |
| #257 | def output( |
| #258 | self, |
| #259 | pid: str, |
| #260 | message: str, |
| #261 | human: str | None = None, |
| #262 | channel: str | None = None, |
| #263 | ) -> dict[str, Any]: |
| #264 | selected_human = human or self.config.runtime.default_human |
| #265 | selected_channel = channel or self.config.runtime.terminal_channel |
| #266 | if selected_channel != self.config.runtime.terminal_channel: |
| #267 | selected_channel = self.config.runtime.terminal_channel |
| #268 | resource = f"human:{selected_human}" |
| #269 | self.capabilities.require(pid, resource, CapabilityRight.WRITE) |
| #270 | request = HumanRequest( |
| #271 | request_id=new_id("hreq"), |
| #272 | pid=pid, |
| #273 | human=selected_human, |
| #274 | payload={"type": "output", "message": message, "channel": selected_channel}, |
| #275 | status=HumanRequestStatus.PENDING, |
| #276 | decision=None, |
| #277 | blocking=False, |
| #278 | created_at=utc_now(), |
| #279 | updated_at=utc_now(), |
| #280 | ) |
| #281 | self.store.insert_human_request(request) |
| #282 | delivered = self._deliver_output_request(request) |
| #283 | return { |
| #284 | "delivered": True, |
| #285 | "request_id": delivered.request_id, |
| #286 | "channel": selected_channel, |
| #287 | "chars": len(message), |
| #288 | } |
| #289 | |
| #290 | def get(self, request_id: str) -> HumanRequest: |
| #291 | request = self.store.get_human_request(request_id) |
| #292 | if request is None: |
| #293 | raise NotFound(f"human request not found: {request_id}") |
| #294 | return request |
| #295 | |
| #296 | def list(self, pid: str | None = None) -> builtins.list[HumanRequest]: |
| #297 | return self.store.list_human_requests(pid=pid) |
| #298 | |
| #299 | def pending(self, human: str | None = None) -> builtins.list[HumanRequest]: |
| #300 | requests = [request for request in self.store.list_human_requests() if request.status == HumanRequestStatus.PENDING] |
| #301 | if human is not None: |
| #302 | requests = [request for request in requests if request.human == human] |
| #303 | return requests |
| #304 | |
| #305 | def process_next_terminal( |
| #306 | self, |
| #307 | human: str | None = None, |
| #308 | auto_approve: bool | None = None, |
| #309 | auto_policy: str | None = None, |
| #310 | auto_answer: str | None = None, |
| #311 | ) -> HumanRequest | None: |
| #312 | selected_human = human or self.config.runtime.default_human |
| #313 | pending = self.pending(human=selected_human) |
| #314 | if not pending: |
| #315 | return None |
| #316 | # The terminal is the human's message queue. Process requests strictly |
| #317 | # in creation order so approvals and answers remain predictable. |
| #318 | request = pending[0] |
| #319 | request_type = request.payload.get("type") |
| #320 | if request_type == "output": |
| #321 | return self._deliver_output_request(request) |
| #322 | question = self._terminal_question(request) |
| #323 | if request_type == "question": |
| #324 | answer = self._select_text_answer( |
| #325 | question=question, |
| #326 | auto_answer=auto_answer, |
| #327 | ) |
| #328 | return self.approve( |
| #329 | request.request_id, |
| #330 | {"approved": True, "answer": answer, "source": "terminal_queue"}, |
| #331 | ) |
| #332 | if request_type == "permission_request": |
| #333 | policy = self._select_permission_policy( |
| #334 | question=question, |
| #335 | auto_policy=auto_policy, |
| #336 | auto_approve=auto_approve, |
| #337 | ) |
| #338 | decision = {"policy": policy, "source": "terminal_queue"} |
| #339 | if policy == CapabilityManager.ALWAYS_DENY: |
| #340 | return self.reject(request.request_id, {"approved": False, **decision}) |
| #341 | return self.approve(request.request_id, {"approved": True, **decision}) |
| #342 | |
| #343 | approved = self._select_boolean_approval( |
| #344 | question=question, |
| #345 | auto_approve=auto_approve, |
| #346 | ) |
| #347 | if approved: |
| #348 | return self.approve(request.request_id, {"approved": True, "source": "terminal_queue"}) |
| #349 | return self.reject(request.request_id, {"approved": False, "source": "terminal_queue"}) |
| #350 | |
| #351 | async def aprocess_next_terminal( |
| #352 | self, |
| #353 | human: str | None = None, |
| #354 | auto_approve: bool | None = None, |
| #355 | auto_policy: str | None = None, |
| #356 | auto_answer: str | None = None, |
| #357 | ) -> HumanRequest | None: |
| #358 | return await asyncio.to_thread( |
| #359 | self.process_next_terminal, |
| #360 | human=human, |
| #361 | auto_approve=auto_approve, |
| #362 | auto_policy=auto_policy, |
| #363 | auto_answer=auto_answer, |
| #364 | ) |
| #365 | |
| #366 | def drain_terminal_queue( |
| #367 | self, |
| #368 | human: str | None = None, |
| #369 | auto_approve: bool | None = None, |
| #370 | auto_policy: str | None = None, |
| #371 | auto_answer: str | None = None, |
| #372 | ) -> builtins.list[HumanRequest]: |
| #373 | processed: builtins.list[HumanRequest] = [] |
| #374 | while True: |
| #375 | request = self.process_next_terminal( |
| #376 | human=human, |
| #377 | auto_approve=auto_approve, |
| #378 | auto_policy=auto_policy, |
| #379 | auto_answer=auto_answer, |
| #380 | ) |
| #381 | if request is None: |
| #382 | return processed |
| #383 | processed.append(request) |
| #384 | |
| #385 | async def adrain_terminal_queue( |
| #386 | self, |
| #387 | human: str | None = None, |
| #388 | auto_approve: bool | None = None, |
| #389 | auto_policy: str | None = None, |
| #390 | auto_answer: str | None = None, |
| #391 | ) -> builtins.list[HumanRequest]: |
| #392 | processed: builtins.list[HumanRequest] = [] |
| #393 | while True: |
| #394 | request = await self.aprocess_next_terminal( |
| #395 | human=human, |
| #396 | auto_approve=auto_approve, |
| #397 | auto_policy=auto_policy, |
| #398 | auto_answer=auto_answer, |
| #399 | ) |
| #400 | if request is None: |
| #401 | return processed |
| #402 | processed.append(request) |
| #403 | |
| #404 | def _decide( |
| #405 | self, |
| #406 | request_id: str, |
| #407 | status: HumanRequestStatus, |
| #408 | decision: dict[str, Any], |
| #409 | responder: str, |
| #410 | ) -> HumanRequest: |
| #411 | request = self.store.get_human_request(request_id) |
| #412 | if request is None: |
| #413 | raise NotFound(f"human request not found: {request_id}") |
| #414 | request.status = status |
| #415 | request.decision = decision |
| #416 | request.updated_at = utc_now() |
| #417 | self.store.update_human_request(request) |
| #418 | permission_related = False |
| #419 | permission_spec = request.payload.get("requested_permission") |
| #420 | if isinstance(permission_spec, dict): |
| #421 | permission_related = True |
| #422 | resource = permission_spec.get("resource") |
| #423 | if not isinstance(resource, str): |
| #424 | raise ValueError("requested permission must include a string resource") |
| #425 | subject = permission_spec.get("subject", request.pid) |
| #426 | if not isinstance(subject, str): |
| #427 | subject = request.pid |
| #428 | rights = permission_spec.get("rights", ["execute"]) |
| #429 | if not isinstance(rights, list): |
| #430 | rights = ["execute"] |
| #431 | policy = str( |
| #432 | decision.get( |
| #433 | "policy", |
| #434 | CapabilityManager.ALWAYS_ALLOW if status == HumanRequestStatus.APPROVED else CapabilityManager.ALWAYS_DENY, |
| #435 | ) |
| #436 | ) |
| #437 | if policy not in { |
| #438 | CapabilityManager.ALWAYS_ALLOW, |
| #439 | CapabilityManager.ALWAYS_DENY, |
| #440 | CapabilityManager.ASK_EACH_TIME, |
| #441 | }: |
| #442 | raise ValueError(f"unknown permission policy: {policy}") |
| #443 | constraints = permission_spec.get("constraints") |
| #444 | self.capabilities.set_permission_policy( |
| #445 | subject=subject, |
| #446 | resource=resource, |
| #447 | rights=rights, |
| #448 | policy=policy, |
| #449 | issued_by=responder, |
| #450 | constraints=constraints if isinstance(constraints, dict) else None, |
| #451 | ) |
| #452 | |
| #453 | once_spec = request.payload.get("requested_once_capability") |
| #454 | if isinstance(once_spec, dict): |
| #455 | permission_related = True |
| #456 | if status == HumanRequestStatus.APPROVED: |
| #457 | resource = once_spec.get("resource") |
| #458 | if not isinstance(resource, str): |
| #459 | raise ValueError("requested one-time capability must include a string resource") |
| #460 | subject = once_spec.get("subject", request.pid) |
| #461 | if not isinstance(subject, str): |
| #462 | subject = request.pid |
| #463 | rights = once_spec.get("rights", ["execute"]) |
| #464 | if not isinstance(rights, list): |
| #465 | rights = ["execute"] |
| #466 | constraints = once_spec.get("constraints") |
| #467 | self.capabilities.grant_once( |
| #468 | subject=subject, |
| #469 | resource=resource, |
| #470 | rights=rights, |
| #471 | issued_by=responder, |
| #472 | constraints=constraints if isinstance(constraints, dict) else None, |
| #473 | ) |
| #474 | |
| #475 | if status == HumanRequestStatus.APPROVED: |
| #476 | cap_spec = request.payload.get("requested_capability") |
| #477 | if isinstance(cap_spec, dict): |
| #478 | resource = cap_spec.get("resource") |
| #479 | if not isinstance(resource, str): |
| #480 | raise ValueError("requested capability must include a string resource") |
| #481 | subject = cap_spec.get("subject", request.pid) |
| #482 | if not isinstance(subject, str): |
| #483 | subject = request.pid |
| #484 | rights = cap_spec.get("rights", ["execute"]) |
| #485 | if not isinstance(rights, list): |
| #486 | rights = ["execute"] |
| #487 | constraints = cap_spec.get("constraints") |
| #488 | expires_at = cap_spec.get("expires_at") |
| #489 | self.capabilities.grant( |
| #490 | subject=subject, |
| #491 | resource=resource, |
| #492 | rights=rights, |
| #493 | issued_by=responder, |
| #494 | constraints=constraints if isinstance(constraints, dict) else None, |
| #495 | expires_at=expires_at if isinstance(expires_at, str) else None, |
| #496 | delegable=bool(cap_spec.get("delegable", False)), |
| #497 | ) |
| #498 | process = self.store.get_process(request.pid) |
| #499 | if process is not None and process.status == ProcessStatus.WAITING_HUMAN: |
| #500 | # Permission denials still wake the process so it can observe the |
| #501 | # failed operation and explain what happened. Generic rejected human |
| #502 | # approvals remain a pause/interruption signal. |
| #503 | process.status = ( |
| #504 | ProcessStatus.RUNNABLE |
| #505 | if status == HumanRequestStatus.APPROVED or permission_related |
| #506 | else ProcessStatus.PAUSED |
| #507 | ) |
| #508 | process.status_message = None if status == HumanRequestStatus.APPROVED else f"human rejected {request_id}" |
| #509 | process.updated_at = utc_now() |
| #510 | self.store.update_process(process) |
| #511 | self.events.emit( |
| #512 | EventType.HUMAN_RESPONSE, |
| #513 | source=responder, |
| #514 | target=request.pid, |
| #515 | payload={"request_id": request_id, "status": status.value, "decision": decision}, |
| #516 | ) |
| #517 | self.audit.record( |
| #518 | actor=responder, |
| #519 | action="human.response", |
| #520 | target=f"human_request:{request_id}", |
| #521 | decision={"status": status.value, "decision": decision}, |
| #522 | ) |
| #523 | return request |
| #524 | |
| #525 | def _select_permission_policy( |
| #526 | self, |
| #527 | question: str, |
| #528 | auto_policy: str | None, |
| #529 | auto_approve: bool | None, |
| #530 | ) -> str: |
| #531 | choices = { |
| #532 | CapabilityManager.ALWAYS_ALLOW, |
| #533 | CapabilityManager.ALWAYS_DENY, |
| #534 | CapabilityManager.ASK_EACH_TIME, |
| #535 | } |
| #536 | if auto_policy is not None: |
| #537 | if auto_policy not in choices: |
| #538 | raise ValueError(f"unknown permission policy: {auto_policy}") |
| #539 | self.provider.write(f"{question} [policy={auto_policy}]") |
| #540 | return auto_policy |
| #541 | if auto_approve is not None: |
| #542 | policy = CapabilityManager.ALWAYS_ALLOW if auto_approve else CapabilityManager.ALWAYS_DENY |
| #543 | self.provider.write(f"{question} [policy={policy}]") |
| #544 | return policy |
| #545 | answer = self.provider.read( |
| #546 | f"{question} [a=always allow, d=always deny, e=ask each time; default=d]: " |
| #547 | ).strip().lower() |
| #548 | return { |
| #549 | "a": CapabilityManager.ALWAYS_ALLOW, |
| #550 | "allow": CapabilityManager.ALWAYS_ALLOW, |
| #551 | "always_allow": CapabilityManager.ALWAYS_ALLOW, |
| #552 | "y": CapabilityManager.ALWAYS_ALLOW, |
| #553 | "yes": CapabilityManager.ALWAYS_ALLOW, |
| #554 | "d": CapabilityManager.ALWAYS_DENY, |
| #555 | "deny": CapabilityManager.ALWAYS_DENY, |
| #556 | "always_deny": CapabilityManager.ALWAYS_DENY, |
| #557 | "n": CapabilityManager.ALWAYS_DENY, |
| #558 | "no": CapabilityManager.ALWAYS_DENY, |
| #559 | "e": CapabilityManager.ASK_EACH_TIME, |
| #560 | "each": CapabilityManager.ASK_EACH_TIME, |
| #561 | "ask": CapabilityManager.ASK_EACH_TIME, |
| #562 | "ask_each_time": CapabilityManager.ASK_EACH_TIME, |
| #563 | }.get(answer, CapabilityManager.ALWAYS_DENY) |
| #564 | |
| #565 | def _terminal_question(self, request: HumanRequest) -> str: |
| #566 | question = str(request.payload.get("question") or request.payload) |
| #567 | if request.payload.get("type") == "question": |
| #568 | context = request.payload.get("context") |
| #569 | if not isinstance(context, dict) or not context: |
| #570 | return question |
| #571 | lines = [question, "Context:"] |
| #572 | for key in sorted(context): |
| #573 | lines.append(f"- {key}: {context[key]!r}") |
| #574 | return "\n".join(lines) |
| #575 | if request.payload.get("type") != "external_operation_approval": |
| #576 | return question |
| #577 | context = request.payload.get("context") |
| #578 | if not isinstance(context, dict): |
| #579 | return question |
| #580 | # External-operation prompts show structured facts, not tool prose, so |
| #581 | # the human can judge the primitive-level side effect safely. |
| #582 | capability = request.payload.get("requested_once_capability") |
| #583 | lines = ["Operation details:"] |
| #584 | for label, key in [ |
| #585 | ("process", "pid"), |
| #586 | ("primitive", "primitive"), |
| #587 | ("operation", "operation"), |
| #588 | ("path", "path"), |
| #589 | ("absolute path", "absolute_path"), |
| #590 | ("resource", "resource"), |
| #591 | ("grant scope", "grant_scope"), |
| #592 | ("encoding", "encoding"), |
| #593 | ("overwrite flag", "overwrite"), |
| #594 | ("parents flag", "parents"), |
| #595 | ("exist ok", "exist_ok"), |
| #596 | ("recursive", "recursive"), |
| #597 | ("missing ok", "missing_ok"), |
| #598 | ("will create", "will_create"), |
| #599 | ("will overwrite", "will_overwrite"), |
| #600 | ("content bytes", "content_bytes"), |
| #601 | ("content sha256", "content_sha256"), |
| #602 | ("working directory", "working_directory"), |
| #603 | ("argv", "argv"), |
| #604 | ("command", "command"), |
| #605 | ("timeout seconds", "timeout_s"), |
| #606 | ("policy level", "policy_level"), |
| #607 | ("policy reason", "policy_reason"), |
| #608 | ("matched rule", "matched_rule"), |
| #609 | ("high risk", "high_risk"), |
| #610 | ]: |
| #611 | if key in context: |
| #612 | lines.append(f"- {label}: {context[key]}") |
| #613 | target = context.get("target") |
| #614 | if isinstance(target, dict): |
| #615 | lines.append("- target:") |
| #616 | for key in ["exists", "kind", "size_bytes", "modified_at"]: |
| #617 | if key in target: |
| #618 | lines.append(f" - {key}: {target[key]}") |
| #619 | if isinstance(capability, dict): |
| #620 | lines.append("- one-time capability:") |
| #621 | lines.append(f" - resource: {capability.get('resource')}") |
| #622 | lines.append(f" - rights: {capability.get('rights')}") |
| #623 | preview = context.get("content_preview") |
| #624 | if isinstance(preview, str): |
| #625 | truncated = bool(context.get("content_preview_truncated")) |
| #626 | lines.append(f"- content preview{' (truncated)' if truncated else ''}:") |
| #627 | lines.append(self._indent_block(preview)) |
| #628 | lines.append(question) |
| #629 | return "\n".join(lines) |
| #630 | |
| #631 | def _indent_block(self, text: str) -> str: |
| #632 | if not text: |
| #633 | return " <empty>" |
| #634 | return "\n".join(f" {line}" for line in text.splitlines() or [text]) |
| #635 | |
| #636 | def _select_boolean_approval( |
| #637 | self, |
| #638 | question: str, |
| #639 | auto_approve: bool | None, |
| #640 | ) -> bool: |
| #641 | if auto_approve is None: |
| #642 | answer = self.provider.read(f"{question} [y/N]: ").strip().lower() |
| #643 | return answer in {"y", "yes"} |
| #644 | self.provider.write(f"{question} [{'approved' if auto_approve else 'rejected'}]") |
| #645 | return auto_approve |
| #646 | |
| #647 | def _select_text_answer( |
| #648 | self, |
| #649 | question: str, |
| #650 | auto_answer: str | None, |
| #651 | ) -> str: |
| #652 | if auto_answer is not None: |
| #653 | self.provider.write(f"{question} [answer={auto_answer!r}]") |
| #654 | return auto_answer |
| #655 | return self.provider.read(f"{question} ") |
| #656 | |
| #657 | def _deliver_output_request(self, request: HumanRequest) -> HumanRequest: |
| #658 | message = str(request.payload.get("message", "")) |
| #659 | channel = str(request.payload.get("channel", self.config.runtime.terminal_channel)) |
| #660 | self.provider.write(message) |
| #661 | request.status = HumanRequestStatus.DELIVERED |
| #662 | request.decision = {"delivered": True} |
| #663 | request.updated_at = utc_now() |
| #664 | self.store.update_human_request(request) |
| #665 | resource = f"human:{request.human}" |
| #666 | self.events.emit( |
| #667 | EventType.HUMAN_OUTPUT, |
| #668 | source=request.pid, |
| #669 | target=resource, |
| #670 | payload={"request_id": request.request_id, "channel": channel, "chars": len(message)}, |
| #671 | ) |
| #672 | self.audit.record( |
| #673 | actor=request.pid, |
| #674 | action="human.output", |
| #675 | target=resource, |
| #676 | decision={"request_id": request.request_id, "channel": channel, "chars": len(message), "queued": True}, |
| #677 | ) |
| #678 | return request |
| #679 | |
| #680 | def _default_message_subject(self, kind: ProcessMessageKind) -> str: |
| #681 | if kind == ProcessMessageKind.INTERRUPT: |
| #682 | return "Human interrupt" |
| #683 | return "Human message" |
| #684 |