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
67 changes: 67 additions & 0 deletions fastworkflow/command_metadata_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -613,6 +613,73 @@ def get_command_display_text(

return "\n".join(combined_lines)

@staticmethod
def get_all_contexts_command_display_text(
subject_workflow_path: str,
cme_workflow_path: str,
active_context_name: str,
for_agents: bool = False,
) -> str:
"""Return a display text covering EVERY context, not just the active one.

Commands are grouped by the context that introduces them: the active context
first, then each remaining context with only the commands it *adds*. That keeps
the map complete without repeating inherited commands in every section. Falls
back to the scoped view if the routing definition cannot be read.
"""
base_text = CommandMetadataAPI.get_command_display_text(
subject_workflow_path=subject_workflow_path,
cme_workflow_path=cme_workflow_path,
active_context_name=active_context_name,
for_agents=for_agents,
)

try:
crd = fastworkflow.RoutingRegistry.get_definition(subject_workflow_path)
already_listed = set(crd.contexts.get(active_context_name, ()))

# Order sections shallowest-first so the command
# that ENTERS a context is introduced before the context that requires it.
def _depth(context_name: str) -> int:
try:
return len(crd.context_model.get_ancestor_contexts(context_name))
except Exception:
return 0

sections: List[str] = [base_text]
for context_name in sorted(crd.contexts, key=lambda c: (_depth(c), c)):
if context_name == active_context_name:
continue
new_commands = [
qualified_name
for qualified_name in sorted(crd.contexts.get(context_name, ()))
if qualified_name not in already_listed
and qualified_name.split("/")[-1] != "wildcard"
]
if not new_commands:
continue

parts = [
f"Commands available after entering the {context_name} context "
f"(not callable until then):"
]
for qualified_name in new_commands:
if part := CommandMetadataAPI.get_command_display_text_for_command(
subject_workflow_path=subject_workflow_path,
cme_workflow_path=cme_workflow_path,
active_context_name=context_name,
qualified_command_name=qualified_name,
for_agents=for_agents,
):
parts.append(part)
# Do not re-list these under a later context that also inherits them.
already_listed.update(new_commands)
sections.append("\n".join(parts))

return "\n\n".join(sections)
except Exception as e: # callers must never break on metadata assembly
return base_text
Comment on lines +680 to +681

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (bug_risk): Log or surface minimal details when falling back after an exception

Catching all exceptions and returning base_text protects callers, but it also hides routing/CRD problems entirely. Please add at least a debug or warning log (with sensitive details redacted) so misconfigurations can be detected while still maintaining the "never break" behavior for metadata assembly.

Suggested implementation:

            return "\n\n".join(sections)
        except Exception as e:  # callers must never break on metadata assembly
            logger.warning(
                "Failed to assemble command metadata; falling back to base_text.",
                exc_info=True,
            )
            return base_text

To make this compile and follow existing logging conventions, ensure that:

  1. A module-level logger is defined, for example:
    logger = logging.getLogger(__name__)
  2. The logging module is imported at the top of fastworkflow/command_metadata_api.py if it is not already:
    import logging
    If this file already uses a different logging helper or framework (e.g. a shared logger from your codebase), replace the logger usage in this patch with that existing logger instance instead.


@staticmethod
def get_suggested_commands_metadata(
subject_workflow_path: str,
Expand Down
5 changes: 5 additions & 0 deletions fastworkflow/utils/react.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,11 @@ def resume(self, observation: str):
input_args = stash["input_args"]
max_iters = stash["max_iters"]

# Keep self.inputs pointing at the active run's arg dict so any mid-run refresh
# (e.g. available_commands re-scoping after a context switch) mutates the same
# dict this loop unpacks on each step.
self.inputs = input_args

trajectory[f"observation_{idx}"] = observation
# Mirror the resumed observation (the user's ask_user answer) into
# current_trajectory. Without this the highest-value context — what the
Expand Down
19 changes: 19 additions & 0 deletions fastworkflow/workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,11 @@ def __init__(self, create_key, workflow_snapshot: dict[str, str|int|bool]):
self._current_command_context = None
self._command_context_for_response_generation = None

# Observers invoked when current_command_context changes. Kept on
# the workflow because the setter is the single chokepoint every context switch
# (set_current_*, go_up, reset_context) passes through.
self._context_change_listeners: list = []

# Child workflow ids (parent/child topology lives on the live object so
# it is reclaimed when the object is garbage-collected).
self._children: list[int] = []
Expand Down Expand Up @@ -202,7 +207,21 @@ def is_current_command_context_root(self) -> bool:

@current_command_context.setter
def current_command_context(self, value: Optional[object]) -> None:
changed = value is not self._current_command_context
self._current_command_context = value
if changed:
self._notify_context_change()

def add_context_change_listener(self, listener) -> None:
"""Register a zero-arg callable invoked whenever the current context changes."""
self._context_change_listeners.append(listener)

def _notify_context_change(self) -> None:
for listener in list(self._context_change_listeners):
try:
listener()
except Exception as exc: # a listener must never break context switching
logger.warning(f"context-change listener failed: {exc}")

@property
def root_command_context(self) -> object:
Expand Down
16 changes: 15 additions & 1 deletion fastworkflow/workflow_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,20 @@ def _what_can_i_do(chat_session_obj: fastworkflow.ChatSession) -> str:
active_context_name=current_workflow.current_command_context_name,
)

def _refresh_agent_available_commands(chat_session_obj) -> None:
"""Re-scope the active ReAct agent's ``available_commands`` to the CURRENT context.

Invoked by the workflow's context-change observer (registered in WEC agent init), so it
fires only on an actual context switch. This is the EXECUTOR's scoped view
(_what_can_i_do); it never touches the planner's full map. No-ops if there is no active
agent or it was invoked without available_commands.
"""
agent = getattr(chat_session_obj, "workflow_tool_agent", None)
inputs = getattr(agent, "inputs", None)
if isinstance(inputs, dict) and "available_commands" in inputs:
inputs["available_commands"] = _what_can_i_do(chat_session_obj)


def _intent_misunderstood(
chat_session_obj: fastworkflow.ChatSession) -> str:
"""
Expand Down Expand Up @@ -549,7 +563,7 @@ class TaskPlannerWithTrajectoryAndAgentInputsSignature(dspy.Signature):
next_steps: str = dspy.OutputField(desc="task descriptions as a numbered list of short sentences separated by line breaks")

current_workflow = chat_session_obj.get_active_workflow()
available_commands = CommandMetadataAPI.get_command_display_text(
available_commands = CommandMetadataAPI.get_all_contexts_command_display_text(
subject_workflow_path=current_workflow.folderpath,
cme_workflow_path=fastworkflow.get_internal_workflow_path("command_metadata_extraction"),
active_context_name=current_workflow.current_command_context_name,
Expand Down
10 changes: 10 additions & 0 deletions fastworkflow/workflow_execution_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -725,6 +725,16 @@ def _initialize_agent_functionality(self) -> None:
self, execution_insights=self._execution_insights
)

# Re-scope the active ReAct agent's available_commands whenever the context changes,
# driven by the workflow's context-change observer (the single switch chokepoint)
# Registered once per WEC (agent init runs once). The listener reads the *active* agent
# dynamically. No-ops when no agent is running.
if self._app_workflow is not None:
from fastworkflow.workflow_agent import _refresh_agent_available_commands
self._app_workflow.add_context_change_listener(
lambda: _refresh_agent_available_commands(self)
)

from fastworkflow.intent_clarification_agent import initialize_intent_clarification_agent
self._intent_clarification_agent = initialize_intent_clarification_agent(self)

Expand Down
Loading