a5629295ac
New runtime/code: - src/ask_user_runtime.py - src/team_runtime.py New real tools in src/agent_tools.py: - ask_user_question - team_create - team_delete - team_list - team_get - send_message - team_messages - notebook_edit
321 lines
11 KiB
Python
321 lines
11 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any, Callable
|
|
|
|
|
|
DEFAULT_ASK_USER_STATE_FILE = Path('.port_sessions') / 'ask_user_runtime.json'
|
|
|
|
|
|
def _utc_now() -> str:
|
|
return datetime.now(timezone.utc).isoformat()
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class QueuedUserAnswer:
|
|
answer: str
|
|
question: str | None = None
|
|
question_id: str | None = None
|
|
header: str | None = None
|
|
match: str = 'exact'
|
|
consume: bool = True
|
|
|
|
@classmethod
|
|
def from_dict(cls, payload: dict[str, Any]) -> 'QueuedUserAnswer | None':
|
|
answer = payload.get('answer')
|
|
if not isinstance(answer, str) or not answer.strip():
|
|
return None
|
|
question = payload.get('question')
|
|
if question is not None and not isinstance(question, str):
|
|
question = None
|
|
question_id = payload.get('question_id')
|
|
if question_id is not None and not isinstance(question_id, str):
|
|
question_id = None
|
|
header = payload.get('header')
|
|
if header is not None and not isinstance(header, str):
|
|
header = None
|
|
match = payload.get('match')
|
|
if not isinstance(match, str) or match not in {'exact', 'contains'}:
|
|
match = 'exact'
|
|
consume = payload.get('consume', True)
|
|
if not isinstance(consume, bool):
|
|
consume = True
|
|
return cls(
|
|
answer=answer,
|
|
question=question.strip() if question else None,
|
|
question_id=question_id.strip() if question_id else None,
|
|
header=header.strip() if header else None,
|
|
match=match,
|
|
consume=consume,
|
|
)
|
|
|
|
def matches(
|
|
self,
|
|
*,
|
|
question: str,
|
|
question_id: str | None,
|
|
header: str | None,
|
|
) -> bool:
|
|
if question_id and self.question_id and self.question_id == question_id:
|
|
return True
|
|
if header and self.header and self.header.lower() == header.lower():
|
|
return True
|
|
if self.question is None:
|
|
return False
|
|
if self.match == 'contains':
|
|
return self.question.lower() in question.lower()
|
|
return self.question.strip().lower() == question.strip().lower()
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
return {
|
|
'answer': self.answer,
|
|
'question': self.question,
|
|
'question_id': self.question_id,
|
|
'header': self.header,
|
|
'match': self.match,
|
|
'consume': self.consume,
|
|
}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class AskUserResponse:
|
|
answer: str
|
|
source: str
|
|
matched_question: str | None = None
|
|
question_id: str | None = None
|
|
header: str | None = None
|
|
|
|
|
|
@dataclass
|
|
class AskUserRuntime:
|
|
cwd: Path
|
|
queued_answers: tuple[QueuedUserAnswer, ...] = field(default_factory=tuple)
|
|
history: tuple[dict[str, Any], ...] = field(default_factory=tuple)
|
|
manifests: tuple[str, ...] = field(default_factory=tuple)
|
|
state_path: Path = field(default_factory=lambda: DEFAULT_ASK_USER_STATE_FILE.resolve())
|
|
interactive: bool = False
|
|
input_func: Callable[[str], str] | None = None
|
|
|
|
@classmethod
|
|
def from_workspace(
|
|
cls,
|
|
cwd: Path,
|
|
additional_working_directories: tuple[str, ...] = (),
|
|
*,
|
|
interactive: bool | None = None,
|
|
input_func: Callable[[str], str] | None = None,
|
|
) -> 'AskUserRuntime':
|
|
manifest_paths = _discover_manifest_paths(cwd, additional_working_directories)
|
|
manifest_answers: list[QueuedUserAnswer] = []
|
|
for manifest_path in manifest_paths:
|
|
manifest_answers.extend(_load_answers_from_manifest(manifest_path))
|
|
state_path = cwd.resolve() / DEFAULT_ASK_USER_STATE_FILE
|
|
payload = _load_state_payload(state_path)
|
|
queued_payload = payload.get('queued_answers')
|
|
if isinstance(queued_payload, list):
|
|
queued_answers = tuple(
|
|
answer
|
|
for answer in (
|
|
QueuedUserAnswer.from_dict(item)
|
|
for item in queued_payload
|
|
if isinstance(item, dict)
|
|
)
|
|
if answer is not None
|
|
)
|
|
else:
|
|
queued_answers = tuple(manifest_answers)
|
|
history_payload = payload.get('history')
|
|
history = tuple(item for item in history_payload if isinstance(item, dict)) if isinstance(history_payload, list) else ()
|
|
if interactive is None:
|
|
env_value = os.environ.get('CLAW_ASK_USER_INTERACTIVE', '')
|
|
interactive = env_value.strip().lower() in {'1', 'true', 'yes', 'on'}
|
|
return cls(
|
|
cwd=cwd.resolve(),
|
|
queued_answers=queued_answers,
|
|
history=history,
|
|
manifests=tuple(str(path) for path in manifest_paths),
|
|
state_path=state_path,
|
|
interactive=bool(interactive),
|
|
input_func=input_func,
|
|
)
|
|
|
|
def has_state(self) -> bool:
|
|
return bool(self.queued_answers or self.history or self.manifests)
|
|
|
|
def answer(
|
|
self,
|
|
*,
|
|
question: str,
|
|
choices: tuple[str, ...] = (),
|
|
question_id: str | None = None,
|
|
header: str | None = None,
|
|
allow_free_text: bool = True,
|
|
) -> AskUserResponse:
|
|
for index, entry in enumerate(self.queued_answers):
|
|
if not entry.matches(question=question, question_id=question_id, header=header):
|
|
continue
|
|
if entry.consume:
|
|
queued_answers = list(self.queued_answers)
|
|
queued_answers.pop(index)
|
|
self.queued_answers = tuple(queued_answers)
|
|
response = AskUserResponse(
|
|
answer=entry.answer,
|
|
source='queued',
|
|
matched_question=entry.question,
|
|
question_id=question_id or entry.question_id,
|
|
header=header or entry.header,
|
|
)
|
|
self._record_history(question, response, choices=choices)
|
|
self._persist_state()
|
|
return response
|
|
|
|
if self.interactive and self.input_func is not None:
|
|
prompt_lines = ['# Ask User']
|
|
if header:
|
|
prompt_lines.append(f'header={header}')
|
|
if question_id:
|
|
prompt_lines.append(f'question_id={question_id}')
|
|
prompt_lines.append(question)
|
|
if choices:
|
|
prompt_lines.append('choices=' + ', '.join(choices))
|
|
raw_answer = self.input_func('\n'.join(prompt_lines) + '\nanswer> ')
|
|
answer = raw_answer.strip()
|
|
if not answer:
|
|
raise LookupError('Interactive ask-user prompt returned an empty answer.')
|
|
if choices and not allow_free_text and answer not in choices:
|
|
raise LookupError(
|
|
'Interactive answer did not match the allowed choices: '
|
|
+ ', '.join(choices)
|
|
)
|
|
response = AskUserResponse(
|
|
answer=answer,
|
|
source='interactive',
|
|
question_id=question_id,
|
|
header=header,
|
|
)
|
|
self._record_history(question, response, choices=choices)
|
|
self._persist_state()
|
|
return response
|
|
|
|
raise LookupError(
|
|
'No queued ask-user answer is available. '
|
|
'Add .claw-ask-user.json or enable CLAW_ASK_USER_INTERACTIVE=1 for interactive prompting.'
|
|
)
|
|
|
|
def render_summary(self) -> str:
|
|
lines = [
|
|
f'Ask-user manifests: {len(self.manifests)}',
|
|
f'Queued answers: {len(self.queued_answers)}',
|
|
f'History entries: {len(self.history)}',
|
|
f'Interactive mode: {self.interactive}',
|
|
]
|
|
if self.queued_answers:
|
|
lines.append('- Pending queued answers:')
|
|
for entry in self.queued_answers[:10]:
|
|
label = entry.question_id or entry.header or entry.question or '(wildcard answer)'
|
|
lines.append(f' - {label}')
|
|
if len(self.queued_answers) > 10:
|
|
lines.append(f' - ... plus {len(self.queued_answers) - 10} more')
|
|
return '\n'.join(lines)
|
|
|
|
def render_history(self, *, limit: int = 20) -> str:
|
|
lines = ['# Ask User History', '']
|
|
entries = list(self.history[-limit:])
|
|
if not entries:
|
|
lines.append('No ask-user interactions recorded.')
|
|
return '\n'.join(lines)
|
|
for entry in entries:
|
|
lines.append(f"- {entry.get('created_at', '(unknown time)')} :: {entry.get('question', '(unknown question)')}")
|
|
lines.append(f" - answer={entry.get('answer', '')}")
|
|
lines.append(f" - source={entry.get('source', 'unknown')}")
|
|
if entry.get('choices'):
|
|
lines.append(' - choices=' + ', '.join(entry['choices']))
|
|
return '\n'.join(lines)
|
|
|
|
def _record_history(
|
|
self,
|
|
question: str,
|
|
response: AskUserResponse,
|
|
*,
|
|
choices: tuple[str, ...],
|
|
) -> None:
|
|
entry = {
|
|
'question': question,
|
|
'answer': response.answer,
|
|
'source': response.source,
|
|
'question_id': response.question_id,
|
|
'header': response.header,
|
|
'choices': list(choices),
|
|
'created_at': _utc_now(),
|
|
}
|
|
self.history = (*self.history, entry)
|
|
|
|
def _persist_state(self) -> None:
|
|
payload = {
|
|
'queued_answers': [answer.to_dict() for answer in self.queued_answers],
|
|
'history': list(self.history),
|
|
}
|
|
self.state_path.parent.mkdir(parents=True, exist_ok=True)
|
|
self.state_path.write_text(
|
|
json.dumps(payload, ensure_ascii=True, indent=2),
|
|
encoding='utf-8',
|
|
)
|
|
|
|
|
|
def _discover_manifest_paths(cwd: Path, additional_working_directories: tuple[str, ...]) -> tuple[Path, ...]:
|
|
candidates = [
|
|
cwd.resolve() / '.claw-ask-user.json',
|
|
cwd.resolve() / '.claude' / 'ask-user.json',
|
|
]
|
|
for raw_path in additional_working_directories:
|
|
root = Path(raw_path).resolve()
|
|
candidates.extend(
|
|
[
|
|
root / '.claw-ask-user.json',
|
|
root / '.claude' / 'ask-user.json',
|
|
]
|
|
)
|
|
discovered: list[Path] = []
|
|
seen: set[Path] = set()
|
|
for candidate in candidates:
|
|
if not candidate.is_file():
|
|
continue
|
|
resolved = candidate.resolve()
|
|
if resolved in seen:
|
|
continue
|
|
seen.add(resolved)
|
|
discovered.append(resolved)
|
|
return tuple(discovered)
|
|
|
|
|
|
def _load_answers_from_manifest(path: Path) -> list[QueuedUserAnswer]:
|
|
try:
|
|
payload = json.loads(path.read_text(encoding='utf-8'))
|
|
except (OSError, json.JSONDecodeError):
|
|
return []
|
|
answers_payload = payload.get('answers')
|
|
if not isinstance(answers_payload, list):
|
|
return []
|
|
answers: list[QueuedUserAnswer] = []
|
|
for item in answers_payload:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
answer = QueuedUserAnswer.from_dict(item)
|
|
if answer is not None:
|
|
answers.append(answer)
|
|
return answers
|
|
|
|
|
|
def _load_state_payload(path: Path) -> dict[str, Any]:
|
|
if not path.exists():
|
|
return {}
|
|
try:
|
|
payload = json.loads(path.read_text(encoding='utf-8'))
|
|
except (OSError, json.JSONDecodeError):
|
|
return {}
|
|
return payload if isinstance(payload, dict) else {}
|