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: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,11 @@ ExecuteResult result = await runner.ExecuteAsync(new ExecuteRequest { Code = "Ap
- `ValueConverter` turns the result into something that outlives the snippet (Unity: ReflectorNet JSON).
- Execution guards ([§3.6](spec/contract.md#36-execution-guards)): runaway recursion stops with an error before a stack
overflow could end the host, and loops stop with `Timeout` once the time budget is spent, also in synchronous code
on the main thread. Budget: `TimeoutMs` on the request, else `DefaultTimeoutMs` (120 s); stack allowance
`MaxStackBytes` (1 MiB); `ExecutionGuards = false` turns them off.
on the main thread. Budget: `TimeoutMs` on the request, else `DefaultTimeoutMs` (120 s), at most `MaxTimeoutMs`
(0 = no cap); stack allowance `MaxStackBytes` (1 MiB); `ExecutionGuards = false` turns them off. Threads and tasks
the snippet starts (`new Thread`, `ThreadPool`, `Task.Run`, `StartNew`) stop once its run has ended.
- Agent tools: expose `timeoutMs` as a `timeout` argument, default to about 30 s, and cap it below the tool timeout
([§1](spec/contract.md#1-request)).
- Output is limited before it reaches the agent ([§6](spec/contract.md#6-output-limits)): a text value, the logs (first
and last lines kept, 2k per line) and the error, 20k characters each by default (`MaxValueChars`, `MaxLogChars`,
`MaxLogLineChars`, `MaxErrorChars` on the request; 0 = no limit). Includes stay inside `IncludeRoot` unless `AllowIncludeOutsideRoot`.
Expand All @@ -77,7 +80,8 @@ code_exec.run("x = 21\nx * 2", include=["helpers"], include_root=".ai-game-dev/s
code_exec.run_b64(base64_json_request) # JSON in, JSON out: one string literal from C++
code_exec.run("print('x')", max_log_chars=0) # the same limits as C# (JSON: maxLogChars, ...); 0 = no limit
code_exec.run("while True: pass", timeout_ms=5000) # stopped by a watchdog: errorKind Timeout (JSON: timeoutMs)
code_exec.set_default_timeout_ms(60000) # the budget of requests without one (default 120000; 0 = none)
code_exec.set_default_timeout_ms(30000) # the budget of requests without one (default 120000; 0 = none)
code_exec.set_max_timeout_ms(280000) # the most a request may ask for (default 0 = no cap)
code_exec.set_max_recursion_limit(3000) # Python < 3.12: the most a snippet's sys.setrecursionlimit sets (0 = no cap)
```

Expand Down
104 changes: 104 additions & 0 deletions dotnet/CodeExec.Roslyn/src/GuardWriter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -208,9 +208,113 @@ static void Collect(SyntaxNode node, List<Edit> edits, Semantics? semantics)
case TypeDeclarationSyntax type when type.IsKind(SyntaxKind.ClassDeclaration) || type.IsKind(SyntaxKind.RecordDeclaration):
Initializers(type, edits);
break;
case InvocationExpressionSyntax call when semantics != null && call.ArgumentList.Arguments.Count > 0
&& WorkStartNames.Contains(RightmostName(call.Expression)):
Workers(call, call.ArgumentList, semantics, edits);
break;
case ObjectCreationExpressionSyntax creation when semantics != null && creation.ArgumentList != null
&& (RightmostName(creation.Type) == "Thread" || RightmostName(creation.Type) == "Task"):
Workers(creation, creation.ArgumentList, semantics, edits);
break;
case ImplicitObjectCreationExpressionSyntax creation when semantics != null:
Workers(creation, creation.ArgumentList, semantics, edits);
break;
}
}

/// <summary>The methods that start work on another thread or as a task (with the constructors of Thread and Task).</summary>
static readonly HashSet<string> WorkStartNames = new HashSet<string> { "Run", "StartNew", "QueueUserWorkItem", "UnsafeQueueUserWorkItem" };

static string RightmostName(SyntaxNode node) => node switch
{
MemberAccessExpressionSyntax member => member.Name.Identifier.ValueText,
QualifiedNameSyntax qualified => qualified.Right.Identifier.ValueText,
AliasQualifiedNameSyntax alias => alias.Name.Identifier.ValueText,
SimpleNameSyntax simple => simple.Identifier.ValueText,
_ => "",
};

/// <summary>
/// A delegate handed to a thread start becomes <c>Worker((T)(delegate))</c> (<see cref="ExecutionGuard.Worker"/>), one
/// handed to a task start <c>TaskWorker((T)(delegate))</c>, cast to the delegate type it already converts to, so
/// overload resolution picks the same method: the work then belongs to the run and stops once the run has ended.
/// Not inside an expression tree or a query.
/// </summary>
static void Workers(ExpressionSyntax call, ArgumentListSyntax arguments, Semantics semantics, List<Edit> edits)
{
if (semantics.HasErrors || !(semantics.Model.GetSymbolInfo(call).Symbol is IMethodSymbol method) || !(StartsWork(method) is bool task))
return;
foreach (var outer in call.Ancestors())
{
if (outer is QueryExpressionSyntax || (outer is AnonymousFunctionExpressionSyntax function && !semantics.IsDelegate(function)))
return;
}
var entry = task ? ExecutionGuard.TaskWorker : ExecutionGuard.Worker;
foreach (var argument in arguments.Arguments)
{
var expression = argument.Expression;
if (!argument.RefKindKeyword.IsKind(SyntaxKind.None) || !(semantics.Model.GetTypeInfo(expression).ConvertedType is INamedTypeSymbol type)
|| !IsWorkDelegate(type, task) || !CanBeNamed(type))
continue;
var cast = type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
edits.Add(new Edit(expression.SpanStart, entry + "((" + cast + ")(", expression.SpanStart, edits.Count));
edits.Add(new Edit(expression.Span.End, "))", expression.Span.End, edits.Count, closer: true));
}
}

/// <summary>Whether the method starts work: true for a task start, false for a thread start, null for neither.</summary>
static bool? StartsWork(IMethodSymbol method)
{
switch (method.ContainingType?.OriginalDefinition.ToDisplayString())
{
case "System.Threading.Thread":
return method.MethodKind == MethodKind.Constructor ? false : (bool?)null;
case "System.Threading.ThreadPool":
return method.Name == "QueueUserWorkItem" || method.Name == "UnsafeQueueUserWorkItem" ? false : (bool?)null;
case "System.Threading.Tasks.Task<TResult>":
return method.MethodKind == MethodKind.Constructor ? true : (bool?)null;
case "System.Threading.Tasks.Task":
return method.MethodKind == MethodKind.Constructor || method.Name == "Run" ? true : (bool?)null;
case "System.Threading.Tasks.TaskFactory":
case "System.Threading.Tasks.TaskFactory<TResult>":
return method.Name == "StartNew" ? true : (bool?)null;
default:
return null;
}
}

/// <summary>The delegate types <see cref="ExecutionGuard.Worker"/> (thread starts) or <see cref="ExecutionGuard.TaskWorker"/> have an overload for.</summary>
static bool IsWorkDelegate(INamedTypeSymbol type, bool task)
{
switch (type.OriginalDefinition.ToDisplayString())
{
case "System.Action<T>":
return true;
case "System.Threading.ThreadStart":
case "System.Threading.ParameterizedThreadStart":
case "System.Threading.WaitCallback":
return !task;
case "System.Action":
case "System.Func<TResult>":
case "System.Func<T, TResult>":
return task;
default:
return false;
}
}

/// <summary>Whether the type can be written in source: no anonymous or erroneous type anywhere in it.</summary>
static bool CanBeNamed(ITypeSymbol type)
{
if (type.IsAnonymousType || type.TypeKind == TypeKind.Error)
return false;
if (type is IArrayTypeSymbol array)
return CanBeNamed(array.ElementType);
if (type is IPointerTypeSymbol)
return false;
return !(type is INamedTypeSymbol named) || named.TypeArguments.All(CanBeNamed);
}

/// <summary>
/// Field and property initializers run before any constructor body (and with no constructor at all): a class with
/// them gets a first field of its own whose initializer runs the call check (static ones a static field).
Expand Down
133 changes: 128 additions & 5 deletions dotnet/CodeExec.Roslyn/src/SnippetWriter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,27 @@ public Output(string source, TextSpan? returnedExpression, bool hasOtherReturns)
public bool HasOtherReturns { get; }
}

public static Output Write(string code, IReadOnlyList<string> usings, IReadOnlyList<ParameterDeclaration> parameters, bool returnLastExpression)
public static Output Write(string code, IReadOnlyList<string> usings, IReadOnlyList<ParameterDeclaration> parameters, bool returnLastExpression) =>
Write(code, usings, parameters, returnLastExpression, findDeclarations: true);

static Output Write(string code, IReadOnlyList<string> usings, IReadOnlyList<ParameterDeclaration> parameters, bool returnLastExpression, bool findDeclarations)
{
var scriptOptions = new CSharpParseOptions(LanguageVersion.Latest, DocumentationMode.None, SourceCodeKind.Script);
var root = (CompilationUnitSyntax)CSharpSyntaxTree.ParseText(code, scriptOptions).GetRoot();
// Script code takes a top-level `using` as a directive: the `[await] using` of each top-level using declaration
// is blanked out before parsing (positions stay) and put back when its declaration is written.
var declarations = findDeclarations ? UsingDeclarations(code, scriptOptions) : new Dictionary<int, UsingDeclaration>();
var parsed = code;
if (declarations.Count > 0)
{
var chars = code.ToCharArray();
foreach (var d in declarations.Values)
for (var i = d.Start; i < d.KeywordEnd; i++)
if (!char.IsWhiteSpace(chars[i]))
chars[i] = ' ';
parsed = new string(chars);
}
var tree = CSharpSyntaxTree.ParseText(parsed, scriptOptions);
var root = (CompilationUnitSyntax)tree.GetRoot();

// Body = statements, plus members WITHOUT modifiers: `var x = 1;` (a script field) becomes a local and
// `int F() => x;` a local function, so both see the snippet's locals. Members with modifiers become static
Expand All @@ -64,21 +81,26 @@ public static Output Write(string code, IReadOnlyList<string> usings, IReadOnlyL
else
topLevel.Add(member);
}
// A declaration that did not become a statement (`using readonly T x = ...;`) keeps its `using`: its error is the agent's.
if (declarations.Keys.Any(start => !body.Any(b => b.Node.SpanStart == start)))
return Write(code, usings, parameters, returnLastExpression, findDeclarations: false);

var sb = new StringBuilder();
foreach (var e in root.Externs) // extern aliases must precede every using
foreach (var e in root.Externs) // extern aliases must precede every using, global usings every other one
Emit(sb, e.GetLocation(), e.ToString());
foreach (var u in root.Usings.Where(IsGlobal))
Emit(sb, u.GetLocation(), u.ToString());
// The runtime's usings (host defaults + request) map to their index, so a bad one is reported as itself.
for (var i = 0; i < usings.Count; i++)
sb.Append("#line ").Append(i + 1).Append(" \"").Append(RoslynCSharpCompiler.UsingsFile).Append("\"\nusing ").Append(usings[i]).Append(";\n");
foreach (var u in root.Usings)
foreach (var u in root.Usings.Where(u => !IsGlobal(u)))
Emit(sb, u.GetLocation(), u.ToString());
foreach (var t in topLevel)
Emit(sb, t.GetLocation(), t.ToString());

// One pass over the snippet's own scope (nested functions have their own `return` / `await`): `await`
// expressions, `await foreach` and `await using` all carry the await keyword.
bool isAsync = false, hasValueReturns = false, hasReturns = false;
bool isAsync = declarations.Values.Any(d => d.Await), hasValueReturns = false, hasReturns = false;
foreach (var (node, _) in body)
{
foreach (var item in node.DescendantNodesAndTokensAndSelf(n => !IsNestedFunction(n)))
Expand Down Expand Up @@ -126,6 +148,12 @@ public static Output Write(string code, IReadOnlyList<string> usings, IReadOnlyL
for (var i = 0; i < body.Count; i++)
{
var (node, text) = body[i];
if (declarations.TryGetValue(node.SpanStart, out var declaration))
{
// The agent's own text from `[await] using` on: a declaration has no `return` of its own to rewrite.
Emit(sb, Location.Create(tree, new TextSpan(declaration.Start, 0)), code.Substring(declaration.Start, node.Span.End - declaration.Start));
continue;
}
if (returnsValue && node is StatementSyntax statement)
text = new BareReturnRewriter().Visit(statement)!.ToString();
if (node is ExpressionStatementSyntax expression)
Expand All @@ -152,6 +180,101 @@ public static Output Write(string code, IReadOnlyList<string> usings, IReadOnlyL
return new Output(sb.ToString(), returned, hasReturns);
}

/// <summary>A top-level <c>[await] using</c> declaration: where its keywords start and end.</summary>
readonly struct UsingDeclaration
{
public UsingDeclaration(int start, int keywordEnd, bool isAwait)
{
Start = start;
KeywordEnd = keywordEnd;
Await = isAwait;
}

public int Start { get; }
public int KeywordEnd { get; }
public bool Await { get; }
}

/// <summary>
/// The top-level using declarations (<c>using var x = ...;</c>, <c>await using T x = ...;</c>), by the position of the
/// declaration after their keywords. A <c>using</c> that starts a statement is a directive only as <c>using static</c>,
/// <c>using unsafe</c>, an alias (<c>using A = ...;</c>) or a name with no <c>=</c> before its <c>;</c>; <c>using (</c>
/// is a using statement, which script code already parses as one.
/// </summary>
static Dictionary<int, UsingDeclaration> UsingDeclarations(string code, CSharpParseOptions options)
{
var found = new Dictionary<int, UsingDeclaration>();
if (code.IndexOf("using", System.StringComparison.Ordinal) < 0)
return found;
var tokens = SyntaxFactory.ParseTokens(code, options: options).ToList();
var depth = 0;
for (var i = 0; i < tokens.Count; i++)
{
var kind = tokens[i].Kind();
if (kind == SyntaxKind.OpenBraceToken || kind == SyntaxKind.OpenParenToken || kind == SyntaxKind.OpenBracketToken)
depth++;
else if (kind == SyntaxKind.CloseBraceToken || kind == SyntaxKind.CloseParenToken || kind == SyntaxKind.CloseBracketToken)
depth = System.Math.Max(depth - 1, 0);
if (depth != 0 || kind != SyntaxKind.UsingKeyword)
continue;
var first = i > 0 && tokens[i - 1].Text == "await" ? i - 1 : i;
if (first > 0 && !tokens[first - 1].IsKind(SyntaxKind.SemicolonToken) && !tokens[first - 1].IsKind(SyntaxKind.CloseBraceToken))
continue; // not at the start of a statement (`global using`, ...)
if (i + 1 < tokens.Count && IsDeclaration(tokens, i + 1))
found[tokens[i + 1].SpanStart] = new UsingDeclaration(tokens[first].SpanStart, tokens[i].Span.End, first != i);
}
return found;
}

/// <summary>Whether the tokens after a statement-starting <c>using</c> (from <paramref name="at"/>) declare variables.</summary>
/// <remarks>
/// <c>Type name =</c>: the variable's name right before the first <c>=</c> outside brackets, a type before it with no
/// two names in a row (<c>using System</c> missing its <c>;</c> before <c>var x = 1;</c> stays a directive). A keyword
/// other than a predefined type (<c>static</c>, <c>unsafe</c>, a modifier) means a directive or invalid code.
/// </remarks>
static bool IsDeclaration(List<SyntaxToken> tokens, int at)
{
var next = tokens[at].Kind();
if (next == SyntaxKind.OpenParenToken || (SyntaxFacts.IsKeywordKind(next) && !SyntaxFacts.IsPredefinedType(next)))
return false;
var depth = 0;
for (var i = at; i < tokens.Count; i++)
{
switch (tokens[i].Kind())
{
case SyntaxKind.OpenParenToken:
case SyntaxKind.OpenBracketToken:
depth++;
break;
case SyntaxKind.CloseParenToken:
case SyntaxKind.CloseBracketToken:
depth--;
break;
case SyntaxKind.EqualsToken when depth == 0:
return i - 1 > at && tokens[i - 1].IsKind(SyntaxKind.IdentifierToken) && !HasAdjacentNames(tokens, at, i - 1);
case SyntaxKind.SemicolonToken:
case SyntaxKind.OpenBraceToken:
case SyntaxKind.CloseBraceToken:
case SyntaxKind.EndOfFileToken:
return false;
}
}
return false;
}

/// <summary>Two names in a row in tokens [<paramref name="from"/>, <paramref name="to"/>): not one type.</summary>
static bool HasAdjacentNames(List<SyntaxToken> tokens, int from, int to)
{
for (var i = from + 1; i < to; i++)
if (IsName(tokens[i - 1]) && IsName(tokens[i]))
return true;
return false;
}

static bool IsName(SyntaxToken token) => token.IsKind(SyntaxKind.IdentifierToken) || SyntaxFacts.IsPredefinedType(token.Kind());

static bool IsGlobal(UsingDirectiveSyntax u) => u.GetFirstToken().IsKind(SyntaxKind.GlobalKeyword);

/// <summary>
/// Appends <paramref name="text"/> mapped to the agent's line, indented to the agent's column so diagnostics keep
/// both; returns where the text starts.
Expand Down
Loading
Loading