diff --git a/fastworkflow/command_metadata_api.py b/fastworkflow/command_metadata_api.py index d338f32..cc2eb8e 100644 --- a/fastworkflow/command_metadata_api.py +++ b/fastworkflow/command_metadata_api.py @@ -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 + @staticmethod def get_suggested_commands_metadata( subject_workflow_path: str, diff --git a/fastworkflow/utils/react.py b/fastworkflow/utils/react.py index c95b4bb..badc2b0 100644 --- a/fastworkflow/utils/react.py +++ b/fastworkflow/utils/react.py @@ -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 diff --git a/fastworkflow/workflow.py b/fastworkflow/workflow.py index f50c9fe..2682a9d 100644 --- a/fastworkflow/workflow.py +++ b/fastworkflow/workflow.py @@ -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] = [] @@ -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: diff --git a/fastworkflow/workflow_agent.py b/fastworkflow/workflow_agent.py index 07e7329..318d02f 100644 --- a/fastworkflow/workflow_agent.py +++ b/fastworkflow/workflow_agent.py @@ -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: """ @@ -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, diff --git a/fastworkflow/workflow_execution_context.py b/fastworkflow/workflow_execution_context.py index 183ab8b..1e4cb37 100644 --- a/fastworkflow/workflow_execution_context.py +++ b/fastworkflow/workflow_execution_context.py @@ -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)