Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions src/foundation/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
PolicyDecisionType,
PresentationNoticeLevel,
ProviderMessage,
QuestionAction,
RenderMode,
ResumeTarget,
SessionKind,
Expand Down Expand Up @@ -529,6 +530,35 @@ def _prompt_for_approval(request: ApprovalRequest) -> bool:
renderer.resume()


def _prompt_for_question(question: QuestionAction) -> str | None:
lines = [f"[bold]{escape(question.prompt)}[/bold]"]
if question.options:
lines.append("")
for index, option in enumerate(question.options, start=1):
lines.append(f" [cyan]{index}[/cyan]. {escape(option)}")
renderer = get_active_renderer()
if renderer is not None:
renderer.pause()
try:
console.print(Panel.fit("\n".join(lines), title="Question"))
try:
raw = typer.prompt("Your answer")
except (EOFError, click.exceptions.Abort):
return None
answer = raw.strip()
if not answer:
return None
# Allow selecting an option by its number.
if question.options and answer.isdigit():
choice = int(answer)
if 1 <= choice <= len(question.options):
return question.options[choice - 1]
return answer
finally:
if renderer is not None:
renderer.resume()


def _build_orchestrator(
settings: AppSettings,
*,
Expand All @@ -549,6 +579,7 @@ def _build_orchestrator(
),
history_store=_build_history_store(settings),
shell_output_callback=shell_output_callback,
question_callback=_prompt_for_question,
capability_registry=_build_capability_registry(settings, tool_service=tool_service),
)

Expand Down Expand Up @@ -2151,6 +2182,29 @@ def _approval_required_notice(
)


def _awaiting_input_notice(
result: OrchestrationResult,
) -> ChatNotice | None:
"""Surface an unanswered question (non-interactive / dismissed) as a notice."""
if result.stop_reason is not LoopStopReason.AWAITING_USER_INPUT:
return None
prompts = [
str(item.artifact.get("question", "")).strip()
for item in result.execution_results
if item.artifact_type is ExecutionArtifactType.QUESTION
and item.artifact is not None
and item.status is ExecutionStatus.AWAITING_INPUT
]
question_text = next((prompt for prompt in prompts if prompt), "a question")
return ChatNotice(
level=PresentationNoticeLevel.WARNING,
text=(
f'Waiting on your input: "{question_text}" '
"Re-run with your answer in the request to continue."
),
)


def _build_chat_turn_presentation(
result: OrchestrationResult,
*,
Expand Down Expand Up @@ -2191,6 +2245,7 @@ def _build_chat_turn_presentation(
_iteration_commands_notice(result),
_verification_outcome_notice(result),
_approval_required_notice(result),
_awaiting_input_notice(result),
):
if iteration_notice is not None and iteration_notice.text not in seen_messages:
notices.append(iteration_notice)
Expand Down
17 changes: 17 additions & 0 deletions src/foundation/live_turn.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@
EVENT_ITERATION_STARTED,
EVENT_PLAN_FINISHED,
EVENT_PLAN_STARTED,
EVENT_QUESTION_ANSWERED,
EVENT_QUESTION_ASKED,
EVENT_SESSION_END,
EVENT_SESSION_START,
EVENT_TOOL_CALL_FAILED,
Expand Down Expand Up @@ -74,6 +76,8 @@ class TurnLiveState:
failure_count: int = 0
awaiting_approval: bool = False
approval_summary: str | None = None
awaiting_input: bool = False
question_summary: str | None = None
finished: bool = False
final_status: str | None = None

Expand Down Expand Up @@ -126,6 +130,14 @@ def fold(self, event_name: str, payload: Mapping[str, Any]) -> None:
self.awaiting_approval = False
self.approval_summary = None
return
if event_name == EVENT_QUESTION_ASKED:
self.awaiting_input = True
self.question_summary = str(payload.get("prompt") or "a question")
return
if event_name == EVENT_QUESTION_ANSWERED:
self.awaiting_input = False
self.question_summary = None
return
if event_name == EVENT_ITERATION_COMPLETED:
return
if event_name == EVENT_SESSION_END:
Expand Down Expand Up @@ -178,6 +190,11 @@ def render_status_line(state: TurnLiveState, *, elapsed_seconds: float) -> Rende
if state.approval_summary:
text.append(f" · {state.approval_summary}", style="yellow")
return text
if state.awaiting_input:
text = Text("⏸ awaiting your answer", style="yellow")
if state.question_summary:
text.append(f" · {state.question_summary}", style="yellow")
return text
if state.current_action_id is not None:
action_elapsed = (
time.monotonic() - state.current_action_started_at
Expand Down
2 changes: 2 additions & 0 deletions src/foundation/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@
ProviderResponseFormat,
ProviderResponseMetadata,
ProviderUsage,
QuestionAction,
ShellAction,
ShellActionMode,
ToolCall,
Expand Down Expand Up @@ -243,6 +244,7 @@
"ProviderResponseFormat",
"ProviderResponseMetadata",
"ProviderUsage",
"QuestionAction",
"RenderMode",
"RiskClass",
"ResumeTarget",
Expand Down
28 changes: 28 additions & 0 deletions src/foundation/models/orchestration.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ class ActionKind(StrEnum):
EXPLANATION = "explanation"
SHELL = "shell"
TOOL_CALL = "tool_call"
QUESTION = "question"


class PolicyDecisionType(StrEnum):
Expand All @@ -63,6 +64,7 @@ class ExecutionStatus(StrEnum):
NOT_EXECUTED = "not_executed"
EXECUTED = "executed"
PENDING_APPROVAL = "pending_approval"
AWAITING_INPUT = "awaiting_input"
BLOCKED = "blocked"
FAILED = "failed"

Expand All @@ -89,13 +91,15 @@ class ExecutionArtifactType(StrEnum):
GIT_STAGE = "git_stage"
GIT_UNSTAGE = "git_unstage"
GIT_COMMIT = "git_commit"
QUESTION = "question"


class LoopStopReason(StrEnum):
"""Why the bounded replan loop terminated."""

ZERO_ACTION_PLAN = "zero_action_plan"
PENDING_APPROVAL = "pending_approval"
AWAITING_USER_INPUT = "awaiting_user_input"
FATAL_EXECUTION_FAILURE = "fatal_execution_failure"
MAX_ITERATIONS = "max_iterations"
MAX_ACTIONS = "max_actions"
Expand Down Expand Up @@ -219,6 +223,24 @@ def _normalize_args(cls, value: object) -> list[str]:
return [str(item) for item in value]


class QuestionAction(StrictModel):
"""A clarifying question the model asks the user mid-turn."""

prompt: str = Field(min_length=1, max_length=1000)
options: list[str] | None = None
allow_free_text: bool = True

@field_validator("options", mode="before")
@classmethod
def _normalize_options(cls, value: object) -> list[str] | None:
if value is None:
return None
if not isinstance(value, list | tuple):
raise TypeError("options must be a list or tuple")
normalized = [str(item) for item in value]
return normalized or None


class PlannedAction(StrictModel):
"""One validated action returned by the planning model."""

Expand All @@ -230,6 +252,7 @@ class PlannedAction(StrictModel):
explanation: str | None = None
shell: ShellAction | None = None
tool_call: ToolCall | None = None
question: QuestionAction | None = None

@model_validator(mode="after")
def _validate_payload_shape(self) -> PlannedAction:
Expand All @@ -248,6 +271,11 @@ def _validate_payload_shape(self) -> PlannedAction:
raise ValueError("Tool-call actions require the tool_call field")
if self.explanation is not None or self.shell is not None:
raise ValueError("Tool-call actions cannot include explanation or shell payloads")
elif self.kind is ActionKind.QUESTION:
if self.question is None:
raise ValueError("Question actions require the question field")
if self.shell is not None or self.tool_call is not None:
raise ValueError("Question actions cannot include shell or tool payloads")

if self.requires_approval and not self.approval_reason:
raise ValueError("Approval-required actions must include approval_reason")
Expand Down
2 changes: 2 additions & 0 deletions src/foundation/observability.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@
EVENT_SHELL_EXECUTION_FAILED = "shell_execution_failed"
EVENT_APPROVAL_REQUESTED = "approval_requested"
EVENT_APPROVAL_RESOLVED = "approval_resolved"
EVENT_QUESTION_ASKED = "question_asked"
EVENT_QUESTION_ANSWERED = "question_answered"
EVENT_EXCEPTION = "exception"
EVENT_RETRY = "retry"
EVENT_ITERATION_STARTED = "iteration_started"
Expand Down
79 changes: 79 additions & 0 deletions src/foundation/services/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import time
from collections.abc import Callable
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
Expand All @@ -19,6 +20,7 @@
PolicyDecision,
PolicyDecisionType,
PolicyEvaluationRecord,
QuestionAction,
ShellAction,
ToolCall,
)
Expand Down Expand Up @@ -51,6 +53,8 @@
from foundation.observability import (
EVENT_APPROVAL_REQUESTED,
EVENT_APPROVAL_RESOLVED,
EVENT_QUESTION_ANSWERED,
EVENT_QUESTION_ASKED,
EVENT_TOOL_CALL_FAILED,
EVENT_TOOL_CALL_FINISHED,
EVENT_TOOL_CALL_STARTED,
Expand Down Expand Up @@ -120,6 +124,7 @@ def __init__(
shell_output_callback: OutputCallback | None = None,
file_service: FileService | None = None,
git_service: GitService | None = None,
question_callback: Callable[[QuestionAction], str | None] | None = None,
) -> None:
self._workspace_root = Path(workspace_root).expanduser().resolve()
self._shell_runtime = shell_runtime
Expand All @@ -131,6 +136,7 @@ def __init__(
self._shell_output_callback = shell_output_callback
self._file_service = file_service
self._git_service = git_service
self._question_callback = question_callback

def execute(
self,
Expand Down Expand Up @@ -164,6 +170,66 @@ def execute(
duration_seconds=max(time.monotonic() - started_monotonic, 0.0),
)

def _handle_question(
self,
action_id: str,
question: QuestionAction,
*,
request_id: str,
session_id: str | None,
) -> ExecutionResult:
self._observer.emit(
EVENT_QUESTION_ASKED,
payload={
"request_id": request_id,
"action_id": action_id,
"prompt": question.prompt,
"options": question.options,
},
session_id=session_id,
logger_name="foundation.services.executor",
)
artifact: dict[str, object] = {
"question": question.prompt,
"options": question.options,
}
# No interactive prompt available (non-TTY / one-shot run): stop the
# loop and surface the question rather than guessing an answer.
if self._question_callback is None:
return ExecutionResult(
action_id=action_id,
status=ExecutionStatus.AWAITING_INPUT,
summary=question.prompt,
artifact_type=ExecutionArtifactType.QUESTION,
artifact={**artifact, "answer": None},
)
answer = self._question_callback(question)
self._observer.emit(
EVENT_QUESTION_ANSWERED,
payload={
"request_id": request_id,
"action_id": action_id,
"answered": answer is not None,
},
session_id=session_id,
logger_name="foundation.services.executor",
)
if answer is None:
return ExecutionResult(
action_id=action_id,
status=ExecutionStatus.AWAITING_INPUT,
summary=f"Unanswered question: {question.prompt}",
artifact_type=ExecutionArtifactType.QUESTION,
artifact={**artifact, "answer": None},
)
return ExecutionResult(
action_id=action_id,
status=ExecutionStatus.EXECUTED,
summary=f"Asked the user: {question.prompt}",
artifact_type=ExecutionArtifactType.QUESTION,
artifact={**artifact, "answer": answer},
)

def _handle_action(
self,
action: PlannedAction,
Expand Down Expand Up @@ -287,6 +353,19 @@ def _handle_action(
approval_resolution,
)

if action.kind is ActionKind.QUESTION:
assert action.question is not None
return (
self._handle_question(
action.id,
action.question,
request_id=request_id,
session_id=session_id,
),
approval_request,
approval_resolution,
)

if policy_evaluation is not None:
self._policy_engine.register_invocation(policy_evaluation)

Expand Down
2 changes: 1 addition & 1 deletion src/foundation/services/guardrails.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ def evaluate(
request_cwd: Path,
approval_mode: ApprovalMode,
) -> PolicyEvaluationRecord | None:
if action.kind is ActionKind.EXPLANATION:
if action.kind in (ActionKind.EXPLANATION, ActionKind.QUESTION):
return None

requested = self._requested_invocation(action, request_cwd=request_cwd)
Expand Down
Loading
Loading