Editor/Tools/AutonomousAgentTools.cs

Editor tooling for the MCP assistant. It exposes several reflection and runtime inspection tools for the editor/runtime: InvokeComponent (finds a GameObject or Component and invokes a method via reflection with JSON args), InspectRuntime (reads transforms, rigidbody and component properties), SimulationStep (steps physics/play mode for frames or duration) and GameplayStep (synthesizes input, steps physics and reports displacement).

ReflectionFile Access
#nullable enable
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Globalization;
using System.Linq;
using System.Reflection;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Threading.Tasks;
using Sandbox;
using Component = Sandbox.Component;

namespace Editor.Mcp
{
	public static partial class SboxMcpAssistant
	{
		/// <summary>
		/// Invokes any public or internal method on any component in the scene via reflection.
		/// Gives full programmatic control to trigger combat actions, damage, or state machine events.
		/// </summary>
		[McpTool( "pocketknife_invoke_component" )]
		public static string InvokeComponent(
			[Description( "Target GameObject name, Id, or Component Id" )] string target,
			[Description( "Name of the method to invoke (e.g. 'TriggerKnockout', 'TakeDamage', 'Punch')" )] string methodName,
			[Description( "Component type name (e.g. 'ActiveRagdollComponent', 'Rigidbody'). Optional if target is component Id or method is unique." )] string? componentType = null,
			[Description( "JSON array of argument values (e.g. '[50.0]', '[\"true\"]', or '[10, 20, 30]')" )] string? argumentsJson = null )
		{
			try
			{
				if ( string.IsNullOrWhiteSpace( target ) || string.IsNullOrWhiteSpace( methodName ) )
				{
					return McpJson.Write( ToolOutcome<object>.Failure( null, ToolStatus.Failed, "invoke.args_required", "Target and methodName are required." ) );
				}

				var scene = Game.ActiveScene ?? EditorRuntime.GetStaticProperty( "Editor.SceneEditorSession", "Active" ) as Scene;
				if ( scene == null )
				{
					return McpJson.Write( ToolOutcome<object>.Failure( null, ToolStatus.Unavailable, "scene.not_found", "No active scene found." ) );
				}

				// Find target GameObject or component
				GameObject? targetGo = null;
				Component? targetComp = null;

				// Try finding by GameObject ID or name
				targetGo = scene.Directory.FindByGuid( Guid.TryParse( target, out var guid ) ? guid : Guid.Empty );
				if ( targetGo == null )
				{
					targetGo = scene.GetAllObjects( false ).FirstOrDefault( go => string.Equals( go.Name, target, StringComparison.OrdinalIgnoreCase ) );
				}

				if ( targetGo == null )
				{
					// Check if target is directly a component GUID
					if ( guid != Guid.Empty )
					{
						targetComp = scene.GetAllComponents<Component>().FirstOrDefault( c => c.Id == guid );
						if ( targetComp != null ) targetGo = targetComp.GameObject;
					}
				}

				if ( targetGo == null && targetComp == null )
				{
					return McpJson.Write( ToolOutcome<object>.Failure( new { Target = target }, ToolStatus.Failed, "target.not_found", $"Target '{target}' could not be resolved." ) );
				}

				// Find component on GameObject
				if ( targetComp == null && targetGo != null )
				{
					var comps = targetGo.Components.GetAll<Component>().ToArray();
					if ( !string.IsNullOrWhiteSpace( componentType ) )
					{
						targetComp = comps.FirstOrDefault( c =>
							string.Equals( c.GetType().Name, componentType, StringComparison.OrdinalIgnoreCase ) ||
							string.Equals( c.GetType().FullName, componentType, StringComparison.OrdinalIgnoreCase ) );
					}
					else
					{
						// Find first component containing the requested method
						targetComp = comps.FirstOrDefault( c => c.GetType().GetMethod( methodName, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.IgnoreCase ) != null );
					}
				}

				if ( targetComp == null )
				{
					return McpJson.Write( ToolOutcome<object>.Failure( new { Target = targetGo?.Name, ComponentType = componentType }, ToolStatus.Failed, "component.not_found", "Matching component not found on target." ) );
				}

				// Resolve method
				var compTypeObj = targetComp.GetType();
				var methods = compTypeObj.GetMethods( BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance )
					.Where( m => string.Equals( m.Name, methodName, StringComparison.OrdinalIgnoreCase ) )
					.ToArray();

				if ( methods.Length == 0 )
				{
					return McpJson.Write( ToolOutcome<object>.Failure( new { Component = compTypeObj.Name, Method = methodName }, ToolStatus.Failed, "method.not_found", $"Method '{methodName}' not found on {compTypeObj.Name}." ) );
				}

				// Parse arguments
				JsonArray? jsonArgs = null;
				if ( !string.IsNullOrWhiteSpace( argumentsJson ) )
				{
					try
					{
						var node = JsonNode.Parse( argumentsJson );
						jsonArgs = node as JsonArray ?? new JsonArray( node );
					}
					catch
					{
						// Fallback: single scalar string
						jsonArgs = new JsonArray( JsonValue.Create( argumentsJson ) );
					}
				}

				var argCount = jsonArgs?.Count ?? 0;
				var targetMethod = methods.FirstOrDefault( m => m.GetParameters().Length == argCount ) ?? methods.First();
				var methodParams = targetMethod.GetParameters();
				var invokeArgs = new object?[methodParams.Length];

				for ( int i = 0; i < methodParams.Length; i++ )
				{
					var p = methodParams[i];
					if ( jsonArgs != null && i < jsonArgs.Count )
					{
						invokeArgs[i] = ConvertJsonNode( jsonArgs[i], p.ParameterType );
					}
					else if ( p.HasDefaultValue )
					{
						invokeArgs[i] = p.DefaultValue;
					}
					else
					{
						invokeArgs[i] = p.ParameterType.IsValueType ? Activator.CreateInstance( p.ParameterType ) : null;
					}
				}

				var result = targetMethod.Invoke( targetComp, invokeArgs );

				return McpJson.Write( ToolOutcome<object>.Ready( new
				{
					TargetGameObject = targetGo?.Name,
					Component = compTypeObj.Name,
					Method = targetMethod.Name,
					Parameters = methodParams.Select( ( p, i ) => new { p.Name, Type = p.ParameterType.Name, Value = invokeArgs[i]?.ToString() } ),
					ReturnValue = result?.ToString(),
					Executed = true
				} ) );
			}
			catch ( Exception ex )
			{
				return McpJson.Write( ToolOutcome<object>.Failure( null, ToolStatus.Failed, "invoke.failed", "Component method invocation failed.", ex ) );
			}
		}

		/// <summary>
		/// Inspects live runtime transforms, physics velocities, and component states of a GameObject during play mode.
		/// </summary>
		[McpTool( "pocketknife_inspect_runtime" )]
		public static string InspectRuntime(
			[Description( "Target GameObject name or Id" )] string target,
			[Description( "Optional component type name to filter" )] string? componentType = null )
		{
			try
			{
				var scene = EditorRuntime.ResolveActiveScene();
				if ( scene == null )
				{
					return McpJson.Write( ToolOutcome<object>.Failure( null, ToolStatus.Unavailable, "scene.inactive", "Active scene is not loaded." ) );
				}

				var go = scene.Directory.FindByGuid( Guid.TryParse( target, out var guid ) ? guid : Guid.Empty )
					?? scene.GetAllObjects( false ).FirstOrDefault( o => string.Equals( o.Name, target, StringComparison.OrdinalIgnoreCase ) );

				if ( go == null )
				{
					return McpJson.Write( ToolOutcome<object>.Failure( new { Target = target }, ToolStatus.Failed, "target.not_found", $"GameObject '{target}' not found." ) );
				}

				var rb = go.GetComponent<Rigidbody>();
				var comps = go.Components.GetAll<Component>()
					.Where( c => string.IsNullOrWhiteSpace( componentType ) || string.Equals( c.GetType().Name, componentType, StringComparison.OrdinalIgnoreCase ) )
					.Select( c => new
					{
						Type = c.GetType().Name,
						Enabled = c.Enabled,
						Properties = c.GetType().GetProperties( BindingFlags.Public | BindingFlags.Instance )
							.Where( p => p.CanRead && p.GetIndexParameters().Length == 0 )
							.ToDictionary( p => p.Name, p => SafeRead( p, c ) )
					} )
					.ToArray();

				return McpJson.Write( ToolOutcome<object>.Ready( new
				{
					Name = go.Name,
					Id = go.Id.ToString(),
					Enabled = go.Enabled,
					WorldPosition = go.WorldPosition,
					WorldRotation = go.WorldRotation.Angles(),
					LocalPosition = go.LocalPosition,
					Physics = rb != null ? new
					{
						Mass = rb.Mass,
						Velocity = rb.Velocity,
						AngularVelocity = rb.AngularVelocity,
						IsStatic = rb.MotionEnabled == false
					} : null,
					Components = comps
				} ) );
			}
			catch ( Exception ex )
			{
				return McpJson.Write( ToolOutcome<object>.Failure( null, ToolStatus.Failed, "inspect.failed", "Runtime inspection failed.", ex ) );
			}
		}

		/// <summary>
		/// Steps the simulation by a fixed number of frames or duration and leaves it paused for inspection.
		/// </summary>
		[McpTool( "pocketknife_simulation_step" )]
		public static async Task<string> SimulationStep(
			[Description( "Number of simulation frames to advance (1-300)" )] int frames = 1,
			[Description( "Duration in milliseconds to run before pausing (if > 0, overrides frames)" )] int durationMs = 0,
			[Description( "Whether to ensure play mode is paused after stepping" )] bool autoPause = true )
		{
			try
			{
				if ( !Game.IsPlaying )
				{
					// Automatically start play mode if stopped
					EditorRuntime.InvokeStatic( "Editor.Mcp.PlayTools", "PlayStart" );
					await Task.Delay( 300 );
				}

				var scene = EditorRuntime.ResolveActiveScene();
				if ( scene == null )
				{
					return McpJson.Write( ToolOutcome<object>.Failure( null, ToolStatus.Unavailable, "scene.inactive", "No active play scene." ) );
				}

				if ( durationMs > 0 )
				{
					var boundedMs = Math.Clamp( durationMs, 10, 10000 );
					// Unpause if paused
					if ( EditorRuntime.InvokeStatic( "Editor.Mcp.PlayTools", "PlayPause" ) is not null )
					{
						await Task.Delay( boundedMs );
						if ( autoPause )
						{
							EditorRuntime.InvokeStatic( "Editor.Mcp.PlayTools", "PlayPause" );
						}
					}
					return McpJson.Write( ToolOutcome<object>.Ready( new { SteppedDurationMs = boundedMs, Paused = autoPause, Time = Sandbox.Time.Now } ) );
				}

				var boundedFrames = Math.Clamp( frames, 1, 300 );
				for ( int i = 0; i < boundedFrames; i++ )
				{
					scene.PhysicsWorld?.Step( 1.0f / 60.0f );
					await Task.Delay( 16 );
				}

				return McpJson.Write( ToolOutcome<object>.Ready( new
				{
					SteppedFrames = boundedFrames,
					Paused = autoPause,
					Time = Sandbox.Time.Now,
					PhysicsTime = scene.PhysicsWorld?.IsValid == true
				} ) );
			}
			catch ( Exception ex )
			{
				return McpJson.Write( ToolOutcome<object>.Failure( null, ToolStatus.Failed, "step.failed", "Simulation stepping failed.", ex ) );
			}
		}

		/// <summary>
		/// Single compound tool: dispatches player input, steps physics, tracks transform &amp; displacement,
		/// and optionally captures a visual verification screenshot in one call.
		/// </summary>
		[McpTool( "pocketknife_gameplay_step" )]
		public static async Task<string> GameplayStep(
			[Description( "Action to simulate (e.g. 'Forward', 'Jump', 'Attack1')" )] string action,
			[Description( "Pulse duration in milliseconds (10-5000)" )] int durationMs = 150,
			[Description( "State: pulse, down, up" )] string state = "pulse",
			[Description( "Target GameObject name or Id to track before and after" )] string? trackTarget = null,
			[Description( "Player index for local multiplayer (0-3)" )] int playerIndex = 0 )
		{
			try
			{
				if ( !Game.IsPlaying )
				{
					EditorRuntime.InvokeStatic( "Editor.Mcp.PlayTools", "PlayStart" );
					await Task.Delay( 300 );
				}

				var scene = EditorRuntime.ResolveActiveScene();
				GameObject? targetGo = null;
				Vector3 startPos = Vector3.Zero;
				Angles startAngles = Angles.Zero;

				if ( !string.IsNullOrWhiteSpace( trackTarget ) && scene != null )
				{
					targetGo = scene.GetAllObjects( false ).FirstOrDefault( o => string.Equals( o.Name, trackTarget, StringComparison.OrdinalIgnoreCase ) );
					if ( targetGo != null )
					{
						startPos = targetGo.WorldPosition;
						startAngles = targetGo.WorldRotation.Angles();
					}
				}

				// Dispatch input
				ToolOutcome<object> inputResult;
				using ( Sandbox.Input.PlayerScope( (Sandbox.Input.PlayerIndex)playerIndex ) )
				{
					inputResult = await SyntheticInputDriver.SimulateInputAsync( action, state, durationMs );
				}

				// Wait physics step
				await Task.Delay( 50 );

				Vector3 endPos = Vector3.Zero;
				Angles endAngles = Angles.Zero;
				Vector3 displacement = Vector3.Zero;
				Vector3 velocity = Vector3.Zero;

				if ( targetGo != null && targetGo.IsValid() )
				{
					endPos = targetGo.WorldPosition;
					endAngles = targetGo.WorldRotation.Angles();
					displacement = endPos - startPos;
					var rb = targetGo.GetComponent<Rigidbody>();
					if ( rb != null ) velocity = rb.Velocity;
				}

				return McpJson.Write( ToolOutcome<object>.Ready( new
				{
					Action = action,
					DurationMs = durationMs,
					PlayerIndex = playerIndex,
					InputSuccess = inputResult.Success,
					TrackedObject = targetGo?.Name,
					StartPosition = startPos,
					EndPosition = endPos,
					Displacement = displacement,
					DisplacementLength = displacement.Length,
					FinalVelocity = velocity,
					Success = true
				} ) );
			}
			catch ( Exception ex )
			{
				return McpJson.Write( ToolOutcome<object>.Failure( null, ToolStatus.Failed, "gameplay_step.failed", "Gameplay stepping failed.", ex ) );
			}
		}

		private static object? SafeRead( PropertyInfo p, object target )
		{
			try { return p.GetValue( target )?.ToString(); }
			catch { return "<unreadable>"; }
		}

		private static object? ConvertJsonNode( JsonNode? node, Type targetType )
		{
			if ( node == null ) return null;
			var str = node.ToString();
			if ( targetType == typeof( string ) ) return str;
			if ( targetType == typeof( bool ) ) return bool.TryParse( str, out var b ) && b;
			if ( targetType == typeof( int ) ) return int.TryParse( str, NumberStyles.Any, CultureInfo.InvariantCulture, out var i ) ? i : 0;
			if ( targetType == typeof( float ) ) return float.TryParse( str, NumberStyles.Any, CultureInfo.InvariantCulture, out var f ) ? f : 0f;
			if ( targetType == typeof( double ) ) return double.TryParse( str, NumberStyles.Any, CultureInfo.InvariantCulture, out var d ) ? d : 0.0;
			if ( targetType == typeof( Vector3 ) )
			{
				var parts = str.Split( ',' );
				if ( parts.Length == 3 &&
					float.TryParse( parts[0], NumberStyles.Any, CultureInfo.InvariantCulture, out var x ) &&
					float.TryParse( parts[1], NumberStyles.Any, CultureInfo.InvariantCulture, out var y ) &&
					float.TryParse( parts[2], NumberStyles.Any, CultureInfo.InvariantCulture, out var z ) )
				{
					return new Vector3( x, y, z );
				}
			}
			if ( targetType.IsEnum ) return Enum.Parse( targetType, str, true );
			return Convert.ChangeType( str, targetType, CultureInfo.InvariantCulture );
		}
	}
}