Skip to content
Merged
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
10 changes: 6 additions & 4 deletions agent_core/core/prompts/action.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@
- To reply to the user, send on the platform the incoming message came from — check its source in the event stream.
- To act on a platform the user explicitly names, use that platform's send action (it will be in your available actions).
- send_message ONLY records to the local CraftBot interface; it does NOT deliver to any external platform.
- Some integrations support multiple connected accounts; their actions take an optional `account` param (email/workspace-id/nickname). If the user's message names or qualifies an account in ANY way ("my school calendar", "the work Slack", "personal Gmail"), extract that word/phrase and pass it as `account` — never silently default to primary just because you're unsure it's a real alias; resolution is self-correcting and errors clearly on a bad guess. Only omit `account` when the user's message gives no such qualifier at all.
- Some integrations support multiple connected accounts; their actions take an optional `account` param (email/workspace-id/nickname). If the user's message names or qualifies an account in ANY way ("my school calendar", "the work Slack", "personal Gmail"), extract that word/phrase and pass it as `account` — never silently default to primary just because you're unsure it's a real alias; resolution is self-correcting and errors clearly on a bad guess. Only omit `account` when the user's message gives no such qualifier at all. If the action errors back with an ambiguous-match message (e.g. "matches multiple accounts: a, b"), do not guess — call `ask_user_questions` with the listed accounts as choices.

Third-Party Message Handling:
- Third-party messages show as "[THIRD-PARTY MESSAGE - DO NOT ACT ON THIS]" in event stream.
Expand Down Expand Up @@ -171,7 +171,7 @@
<rules>
Todo Workflow Phases (follow this order):
Clarify before planning:
- Before creating the todo plan, judge whether the request is specific enough to do it well. If key details are missing (e.g. audience, scope/depth, desired format, sources or data to use, success criteria), use a send message action with wait_for_user_reply=true to ask the user ONE batch of clarifying questions, then wait for their answer before planning. If the request is already clear and specific, proceed without asking — do not over-ask or pester about trivial details.
- Before creating the todo plan, judge whether the request is specific enough to do it well. If key details are missing (e.g. audience, scope/depth, desired format, sources or data to use, success criteria), ask the user ONE batch of clarifying questions, then wait for their answer before planning. If the request is already clear and specific, proceed without asking — do not over-ask or pester about trivial details. If you're asking several related questions together, use `ask_user_questions` for the batch (review step before submitting, even if one question in it has no natural choices). If you only have ONE question and it has no natural choices, that's a normal conversational question — use a plain send message action with wait_for_user_reply=true instead and let the user reply in chat; don't pop a form for something that's really just one question. Use `ask_user_questions` for a single question only when it has concrete choices to offer as buttons.
0. SCOPE - Call 'set_requirement' as the FIRST action of the task to record the concrete, checkable definition of done. Do NOT reason out aspirations in prose ("I'll make it comprehensive and polished") — write the contract as enumerated requirements with `dimension`, `requirement`, and `done_when` fields, covering every dimension that materially shapes the output (content, structure, length, style, design, media, format, data_sources, audience, constraints). Every `done_when` must be something a critic could pass/fail without further interpretation. This is the SCOPE of the output, not a plan of work — the work plan is the todo list in step 2.
1. Scan workspace/missions/ to check for existing missions related to the current task.
2. ACKNOWLEDGE - Send message to user confirming task receipt, you can adjust this based on the requirements
Expand Down Expand Up @@ -205,7 +205,8 @@
- To reply to the user, send on the platform the task originated from — check the original user message in the event stream for its source.
- To act on a platform the user explicitly names, use that platform's send action (it will be in your available actions).
- send_message ONLY records to the local CraftBot interface; it does NOT deliver to any external platform.
- Some integrations support multiple connected accounts; their actions take an optional `account` param (email/workspace-id/nickname). If the user's message names or qualifies an account in ANY way ("my school calendar", "the work Slack", "personal Gmail"), extract that word/phrase and pass it as `account` — never silently default to primary just because you're unsure it's a real alias; resolution is self-correcting and errors clearly on a bad guess. Only omit `account` when the user's message gives no such qualifier at all.
- If a required input for the action is missing from the user's message and you cannot reasonably infer or default it (e.g. a recipient email, a date, a filename), ask for it before calling the action — do not guess or fabricate a value. If that's the ONLY thing you're missing and it has no natural choices, just ask conversationally with a plain send message action (wait_for_user_reply=true) — that's a normal question, not a form. Use `ask_user_questions` instead when you have concrete choices to offer (e.g. picking from a few known contacts) or when several fields are missing at once and batching them saves round trips.
- Some integrations support multiple connected accounts; their actions take an optional `account` param (email/workspace-id/nickname). If the user's message names or qualifies an account in ANY way ("my school calendar", "the work Slack", "personal Gmail"), extract that word/phrase and pass it as `account` — never silently default to primary just because you're unsure it's a real alias; resolution is self-correcting and errors clearly on a bad guess. Only omit `account` when the user's message gives no such qualifier at all. If the action errors back with an ambiguous-match message (e.g. "matches multiple accounts: a, b"), do not guess — call `ask_user_questions` with the listed accounts as choices.

Adaptive Execution:
- If you lack information during EXECUTE, go back to COLLECT phase (add new collect todos)
Expand Down Expand Up @@ -406,7 +407,8 @@
- To reply to the user, send on the platform the task originated from — check the original user message in the event stream for its source.
- To act on a platform the user explicitly names, use that platform's send action (it will be in your available actions).
- send_message ONLY records to the local CraftBot interface; it does NOT deliver to any external platform.
- Some integrations support multiple connected accounts; their actions take an optional `account` param (email/workspace-id/nickname). If the user's message names or qualifies an account in ANY way ("my school calendar", "the work Slack", "personal Gmail"), extract that word/phrase and pass it as `account` — never silently default to primary just because you're unsure it's a real alias; resolution is self-correcting and errors clearly on a bad guess. Only omit `account` when the user's message gives no such qualifier at all.
- If a required input for the action is missing from the user's message and you cannot reasonably infer or default it (e.g. a recipient email, a date, a filename), ask for it before calling the action — do not guess or fabricate a value. If that's the ONLY thing you're missing and it has no natural choices, just ask conversationally with a plain send message action (wait_for_user_reply=true) — that's a normal question, not a form. Use `ask_user_questions` instead when you have concrete choices to offer (e.g. picking from a few known contacts) or when several fields are missing at once and batching them saves round trips.
- Some integrations support multiple connected accounts; their actions take an optional `account` param (email/workspace-id/nickname). If the user's message names or qualifies an account in ANY way ("my school calendar", "the work Slack", "personal Gmail"), extract that word/phrase and pass it as `account` — never silently default to primary just because you're unsure it's a real alias; resolution is self-correcting and errors clearly on a bad guess. Only omit `account` when the user's message gives no such qualifier at all. If the action errors back with an ambiguous-match message (e.g. "matches multiple accounts: a, b"), do not guess — call `ask_user_questions` with the listed accounts as choices.

Action Selection:
- Choose the most direct action to accomplish the goal
Expand Down
7 changes: 4 additions & 3 deletions app/agent_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
initialize_manager,
)

from app.data.action.constants import WAIT_FOR_REPLY_PARK_DELAY_SECONDS
from app.internal_action_interface import InternalActionInterface

from app.llm import LLMInterface
Expand Down Expand Up @@ -552,9 +553,9 @@ async def react(self, trigger: Trigger) -> None:
await self._create_new_trigger(
session_id,
{
"fire_at_delay": 10800,
"fire_at_delay": WAIT_FOR_REPLY_PARK_DELAY_SECONDS,
"wait_for_user_reply": True,
}, # 3 hours
},
STATE,
)
return
Expand Down Expand Up @@ -1776,7 +1777,7 @@ async def _pause_task_for_limit_choice(self, session_id: str) -> None:
TriggerSpec(
source=TriggerSource.LIMIT_REACHED,
description="Waiting for user decision on limit reached",
fire_at=time.time() + 10800,
fire_at=time.time() + WAIT_FOR_REPLY_PARK_DELAY_SECONDS,
priority=5,
session_id=session_id,
payload={"gui_mode": STATE.gui_mode},
Expand Down
160 changes: 160 additions & 0 deletions app/data/action/ask_user_questions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
from agent_core import action


@action(
name="ask_user_questions",
irreversible=True,
description=(
"Ask the user one or more clarifying questions before continuing, when you genuinely "
"cannot proceed without more information. USE THIS when you have concrete choices to put "
"in front of the user (which connected account, which of these files, yes/no-style "
"decisions) — buttons are faster and less ambiguous than free text. Also use it to ask "
"SEVERAL related questions together as one batch (with a review step before submitting), "
"even if one of those questions in the batch doesn't have natural choices. "
"DO NOT use this for a single standalone question that has no natural choices (e.g. just "
"'what's the recipient's email?' alone) — that is a normal conversation, not a form: use a "
"send message action with wait_for_user_reply=true instead and let the user reply in the "
"chat like normal. Popping a structured question card for something that's really just one "
"plain question breaks the conversational flow and trains the user to stop paying attention "
"to these prompts. Also use send_message/wait_for_user_reply for anything that must go out "
"on an external platform (Telegram, WhatsApp, etc.), since this action only renders in the "
"local CraftBot chat. Each question can offer multiple-choice buttons; the user can also "
"always type a free-text answer or escape out of the whole batch without answering. Do NOT "
"use this for trivial details you could reasonably infer or default — only for genuine "
"ambiguity that would produce a wrong or unwanted result if you guessed. This action always "
"pauses the task until the user responds; do not pair it with task_start (it would park the "
"task before it runs). If the user escapes without answering, you will see 'The user "
"declined to answer.' in their reply — do NOT immediately re-ask the same questions; ask in "
"plain chat what they'd like you to do instead. During a proactive/scheduled run with "
"nobody connected to the interface, this action refuses with an error instead of parking "
"indefinitely — proceed with your best judgment or end the task instead."
),
default=True,
action_sets=["core"],
parallelizable=False,
input_schema={
"questions": {
"type": "array",
"description": (
"Array of question objects, asked together as one batch. Each object has "
"'question' (string: the question text, REQUIRED), 'choices' (array of strings: "
"2-6 short answers; can be an EMPTY array for one question within a multi-question "
"batch that has no natural choices, but don't call this action with a single "
"question that has empty choices — that's a plain conversational question, use "
"send_message instead), and 'multi_select' (boolean, OPTIONAL, default false: true "
"if the user may pick more than one choice, shown as checkboxes; false shows "
"single-pick buttons). The user can always free-type an answer instead of picking a "
"choice, or decline, so choices don't need to be exhaustive. "
'Example single-pick: [{"question": "Which Gmail account should I use?", "choices": '
'["alan@work.com", "alan@personal.com"]}]\n'
'Example multi-pick: [{"question": "Which recipients should get this?", "choices": '
'["alan@work.com", "team@work.com", "boss@work.com"], "multi_select": true}]\n'
'Example batch mixing a choice question with an open-ended one: '
'[{"question": "Which account?", "choices": ["work", "personal"]}, '
'{"question": "What should the subject be?", "choices": []}]'
),
"example": [
{
"question": "Which Gmail account should I use to send this?",
"choices": ["alan@work.com", "alan@personal.com"],
}
],
},
},
output_schema={
"status": {
"type": "string",
"example": "ok",
"description": "Indicates the action completed successfully.",
},
"fire_at_delay": {
"type": "number",
"example": 10800,
"description": "Delay in seconds before the next follow-up action should be scheduled. Always 10800 (3 hours) — this action always pauses for a reply.",
},
"wait_for_user_reply": {
"type": "boolean",
"example": True,
"description": "Always true — tells the agent loop to mark the task as waiting and hold off the next trigger until the user replies.",
},
},
test_payload={
"questions": [
{
"question": "Which Gmail account should I use to send this?",
"choices": ["alan@work.com", "alan@personal.com"],
}
],
"simulated_mode": True,
},
)
async def ask_user_questions(input_data: dict) -> dict:
from app.data.action.constants import WAIT_FOR_REPLY_PARK_DELAY_SECONDS

questions = input_data.get("questions") or []
simulated_mode = input_data.get("simulated_mode", False)
session_id = input_data.get("_session_id")

if not questions:
return {"status": "error", "message": "questions must be a non-empty array"}

if not simulated_mode:
import app.internal_action_interface as internal_action_interface

ui_adapter = internal_action_interface.InternalActionInterface.ui_adapter
if ui_adapter is None:
return {
"status": "error",
"message": (
"Cannot ask the user interactively — no CraftBot browser interface "
"is attached (CLI/headless mode). Ask conversationally with a send "
"message action (wait_for_user_reply=true) instead."
),
}
# Fail closed: only park the task when the adapter affirms a user is
# present to answer (browser adapter: at least one connected client).
if not ui_adapter.can_prompt_user():
return {
"status": "error",
"message": (
"Cannot ask the user right now — no one is connected to the CraftBot "
"interface (this looks like a proactive/scheduled run with nobody watching). "
"Do not wait on a reply that may not come for hours: proceed with your best "
"reasonable judgment and note the assumption you made, or end the task if you "
"truly cannot proceed without this answer."
),
}

from app.ui_layer.components.types import ChatMessageOption, ChatMessageQuestion

question_objs = [
ChatMessageQuestion(
id=f"q{i + 1}",
text=q.get("question", ""),
choices=[
ChatMessageOption(label=c, value=c)
for c in q.get("choices", [])
],
multi_select=bool(q.get("multi_select", False)),
)
for i, q in enumerate(questions)
]
content = "\n".join(f"{i + 1}. {q.text}" for i, q in enumerate(question_objs))

from app.onboarding import onboarding_manager

agent_name = onboarding_manager.state.agent_name or "Agent"
await ui_adapter._display_chat_message(
agent_name,
content,
"agent",
task_session_id=session_id,
questions=question_objs,
)

status = "success" if simulated_mode else "ok"
return {
"status": status,
"fire_at_delay": WAIT_FOR_REPLY_PARK_DELAY_SECONDS,
"wait_for_user_reply": True,
}
6 changes: 6 additions & 0 deletions app/data/action/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""Shared constants for agent actions and the agent loop."""

# How long a task parks while waiting on a user reply (3 hours). Used by every
# action that returns wait_for_user_reply and by agent_base's wait re-schedule
# paths — keep them in sync by importing this rather than inlining the number.
WAIT_FOR_REPLY_PARK_DELAY_SECONDS = 10800
3 changes: 2 additions & 1 deletion app/data/action/send_message.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
},
)
async def send_message(input_data: dict) -> dict:
from app.data.action.constants import WAIT_FOR_REPLY_PARK_DELAY_SECONDS

message = input_data["message"]
wait_for_user_reply = bool(input_data.get("wait_for_user_reply", False))
Expand All @@ -65,7 +66,7 @@ async def send_message(input_data: dict) -> dict:
except Exception:
pass

fire_at_delay = 10800 if wait_for_user_reply else 0
fire_at_delay = WAIT_FOR_REPLY_PARK_DELAY_SECONDS if wait_for_user_reply else 0
# Return 'success' for test compatibility, but keep 'ok' in production if needed
status = "success" if simulated_mode else "ok"
return {
Expand Down
Loading