Skip to content
Open
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
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
log/
note/
bench/

# IDE
.idea/
Expand Down Expand Up @@ -32,6 +33,7 @@ project/plugins/project/
.history
.cache
.lib/
*.jar

### SBT Patch ###
.bsp/
Expand Down Expand Up @@ -221,4 +223,6 @@ poetry.toml
.ruff_cache/

# LSP config files
pyrightconfig.json
pyrightconfig.json

tacit_config.json
47 changes: 30 additions & 17 deletions build.sbt
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
val scala3Version = {
val fallback = "3.8.3-RC1"
try {
val url = "https://repo.scala-lang.org/artifactory/api/storage/local-maven-nightlies/org/scala-lang/scala3-compiler_3/"
val content = scala.io.Source.fromURL(url, "UTF-8").mkString
val pattern = """"uri"\s*:\s*"/(3\.[^"]*NIGHTLY)"""".r
val versions = pattern.findAllMatchIn(content).map(_.group(1)).toList.sorted
val latest = versions.last
if (latest != fallback) println(s"[info] Use Scala 3 nightly: $latest")
latest
} catch { case _: Exception =>
println(s"[warn] Failed to fetch latest nightly, using fallback: $fallback")
fallback
}
}
// val scala3Version = "3.8.4-RC1-bin-SNAPSHOT"
// val scala3Version = {
// val fallback = "3.8.3-RC1"
// try {
// val url = "https://repo.scala-lang.org/artifactory/api/storage/local-maven-nightlies/org/scala-lang/scala3-compiler_3/"
// val content = scala.io.Source.fromURL(url, "UTF-8").mkString
// val pattern = """"uri"\s*:\s*"/(3\.[^"]*NIGHTLY)"""".r
// val versions = pattern.findAllMatchIn(content).map(_.group(1)).toList.sorted
// val latest = versions.last
// if (latest != fallback) println(s"[info] Use Scala 3 nightly: $latest")
// latest
// } catch { case _: Exception =>
// println(s"[warn] Failed to fetch latest nightly, using fallback: $fallback")
// fallback
// }
// }
// Need the specific version with dynamic eval published locally
val scala3Version = "3.9.0-RC1-bin-SNAPSHOT"
ThisBuild / resolvers += Resolver.scalaNightlyRepository

val tacitVersion = "0.1.4-SNAPSHOT"
Expand All @@ -35,10 +36,22 @@ lazy val lib = project
Compile / unmanagedSources / excludeFilter :=
"*.test.scala" || "project.scala" || "README.md",
libraryDependencies ++= Seq(
"com.openai" % "openai-java" % "4.32.0",
"com.openai" % "openai-java" % "4.35.0",
"io.circe" %% "circe-core" % circeVersion,
"io.circe" %% "circe-parser" % circeVersion,
"org.scala-lang" %% "scala3-compiler" % scala3Version,
"org.scala-lang" %% "scala3-repl" % scala3Version,
),
// Bundle Interface.scala source as a classpath resource so the agent
// prompt can include the full capability API surface. We place it under
// `tacit/` and use a non-`.scala` extension so the incremental compiler
// doesn't re-discover it as a duplicate source on the next build.
Compile / resourceGenerators += Def.task {
val src = baseDirectory.value / "Interface.scala"
val dst = (Compile / resourceManaged).value / "tacit" / "Interface.scala.txt"
IO.copyFile(src, dst)
Seq(dst)
}.taskValue,
scalacOptions ++= Seq(
"-language:experimental.captureChecking",
"-language:experimental.modularity",
Expand Down
8 changes: 8 additions & 0 deletions library/GlobalIOCap.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package tacit.library
import language.experimental.captureChecking
import caps.*

@assumeSafe
object GlobalIOCap extends IOCapability
// This object serves as the global singleton instance of IOCapability that
// library code can use to access file/network/process capabilities.
35 changes: 29 additions & 6 deletions library/Interface.scala
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package tacit.library

import dotty.tools.repl.eval.{Eval, EvalContext, EvalResult, evalLike, evalSafeLike}

import language.experimental.captureChecking
import caps.*

Expand Down Expand Up @@ -89,7 +91,7 @@ trait ProcessPermission extends caps.SharedCapability:
/** Capability gating access to standard output (`println`, `print`, `printf`).
* An implicit instance is available at the REPL top level. */
@assumeSafe
class IOCapability private extends caps.SharedCapability
class IOCapability private[library] extends caps.SharedCapability

// ─── Interface ──────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -128,9 +130,7 @@ class IOCapability private extends caps.SharedCapability
* ```
*/
@assumeSafe
trait Interface extends SharedCapability:

val iocap: IOCapability
trait Interface:

// ── File System ─────────────────────────────────────────────────────

Expand Down Expand Up @@ -239,8 +239,10 @@ trait Interface extends SharedCapability:

/** Print to the standard output stream visible to the agent.
*
* If the argument is a `Classified[_]`, only the masked form
* `Classified(***)` is written here — the actual content is never shown
* If you want to show results containing classified data to the end user,
* print the classified value in a separate print call so it can be captured by
* the secure output sink. When the argument is a `Classified[_]`, only the masked form
* `Classified(***)` is written here, and the actual content is never shown
Comment on lines 240 to +245
* to the agent. When the host has configured a secure output sink, the
* unwrapped content is additionally written to that sink, which only
* the end user can read. Non-classified arguments are printed normally
Expand Down Expand Up @@ -274,3 +276,24 @@ trait Interface extends SharedCapability:
* val summary: Classified[String] = chat(secret.map(q => s"Summarize the following: $q"))
* ``` */
def chat(message: Classified[String]): Classified[String]

/** Ask the configured trusted LLM to fill the call-site placeholder with a Scala
* expression of type `T`, then compile and run it under the live REPL.
* The synthetic parameters (`bindings`, `expectedType`, `enclosingSource`)
* are populated by the compiler at the call site.
* All functions in `Interface` are in scope at the call site and can be used
* in the generated code.
*
* ```
* val n: Int = agent[Int]("answer to the ultimate question")
* ```
*/
@evalLike def agent[T](
prompt: String,
bindings: Array[Eval.Binding] = Array.empty[Eval.Binding],
expectedType: String = "",
enclosingSource: String = "",
maxAttempts: Int = 3
): T

end Interface
20 changes: 11 additions & 9 deletions library/impl/InterfaceImpl.scala
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,21 @@ package tacit.library
import language.experimental.captureChecking
import caps.*

import dotty.tools.repl.eval.{Eval, evalLike}

import java.io.{File => JFile, PrintStream, FileOutputStream}

@assumeSafe
abstract class InterfaceImpl(
private val config: LibraryConfig = LibraryConfig()
configJson: String
) extends Interface:

private val config = LibraryConfig.fromJson(configJson)

// create real FileSystem by default, but allow tests to override with a virtual one
protected def createFS(root: String, filter: String -> Boolean, classifiedPatterns: Set[String]): FileSystem =
new RealFileSystem(root, filter, classifiedPatterns)
Comment on lines 10 to +19

private val DefaultClassifiedPatterns: Set[String] = Set(
".ssh", ".gnupg", ".env", ".env.*", ".netrc", ".npmrc", ".pypirc",
".docker", ".kube", ".aws", ".azure", ".gcloud",
Expand Down Expand Up @@ -45,19 +53,13 @@ abstract class InterfaceImpl(
case _: Classified[?] => "Classified(***)"
case other => other

protected def createFS(root: String, filter: String -> Boolean, classifiedPatterns: Set[String]): FileSystem

export FileOps.*
export ProcessOps.*
export WebOps.*

// IOCapability's private constructor means user code cannot create one.
// The null sentinel is safe: IOCapability is only used as a type-level
// capability witness, never dereferenced at runtime.
val iocap: IOCapability = null.asInstanceOf[IOCapability]

private val llmOps = LlmOps(llmConfig)
export llmOps.chat

export llmOps.*

def println(x: Any)(using IOCapability): Unit =
// Classified.toString already returns "Classified(***)" so the
Expand Down
125 changes: 125 additions & 0 deletions library/impl/LlmOps.scala
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package tacit.library

import com.openai.client.OpenAIClient
import com.openai.models.ReasoningEffort
import com.openai.client.okhttp.OpenAIOkHttpClient
import com.openai.models.chat.completions.ChatCompletionCreateParams
import dotty.tools.repl.eval.{Eval, EvalContext, EvalResult, evalLike}

class LlmOps(config: Option[LlmConfig]):

Expand All @@ -24,6 +26,7 @@ class LlmOps(config: Option[LlmConfig]):
val params = ChatCompletionCreateParams.builder()
.model(cfg.model)
.addUserMessage(message)
.reasoningEffort(ReasoningEffort.HIGH)
.build()
client.chat().completions().create(params)
.choices().get(0).message().content().orElse("").nn
Expand All @@ -34,3 +37,125 @@ class LlmOps(config: Option[LlmConfig]):
def chat(message: Classified[String]): Classified[String] =
requireConfig()
message.map(chat)

/** Inline-style LLM agent. Asks the LLM to fill the call-site placeholder
* with a Scala expression of the requested type, compiles and runs it
* under the live REPL, and retries with the diagnostic text on compile
* failure. The synthetic parameters (`bindings`, `expectedType`,
* `enclosingSource`) are populated by the eval rewriter at each call
* site; direct callers can leave them at their defaults. */
@evalLike def agent[T](
prompt: String,
bindings: Array[Eval.Binding] = Array.empty[Eval.Binding],
expectedType: String = "",
enclosingSource: String = "",
maxAttempts: Int = 3
): T =
requireConfig()

@annotation.tailrec
def attempt(n: Int, prevCode: String, prevErrors: List[String]): EvalResult[T] =
val request = AgentPrompt.build(
prompt, bindings, expectedType, enclosingSource, prevCode, prevErrors)
val code = AgentPrompt.stripCodeFences(chat(request)).trim
val r = Eval.evalSafe[T](code, bindings, expectedType, enclosingSource)
if r.isSuccess || n >= maxAttempts then r
else attempt(n + 1, code, r.error.nn.errors.toList)

attempt(1, "", Nil).get

private object AgentPrompt:

private val Intro =
"""You are an inline Scala 3 code generator for the live REPL.
|Output ONLY a Scala expression that fills the placeholder in the user's source.
|No markdown fences, no commentary.""".stripMargin

/** The full `Interface.scala` source, bundled as a classpath resource by the
* build, so the LLM sees the exact capability API surface available at the
* REPL call site. */
private lazy val InterfaceReference: String =
val stream = classOf[LlmOps].getResourceAsStream("/tacit/Interface.scala.txt")
if stream != null then
try scala.io.Source.fromInputStream(stream)(using scala.io.Codec.UTF8).mkString
finally stream.close()
else "(Interface.scala source not found on classpath)"

private val InterfaceIntro =
"""You may only interact with the system through the capability-scoped API
|defined below. Do not use Java/Scala standard library APIs (java.io,
|java.nio, scala.io, sys.process, java.net, etc.) directly. All members
|of the `Interface` trait are pre-loaded into scope (via `import api.*`),
|so call them unqualified — e.g. `chat("...")`, `requestFileSystem(...)`.
|""".stripMargin

private def interfaceSection: String =
s"""$InterfaceIntro
|```scala
|$InterfaceReference
|```""".stripMargin

def build(
task: String,
bindings: Array[Eval.Binding],
expectedType: String,
enclosingSource: String,
prevCode: String,
prevErrors: List[String]
): String =
List(
Intro,
interfaceSection,
typeSection(expectedType),
contextSection(enclosingSource),
bindingsSection(bindings),
s"Task: $task",
typeReminder(expectedType),
errorSection(prevCode, prevErrors)
).filter(_.nonEmpty).mkString("\n\n")

private def typeSection(expectedType: String): String =
if expectedType.nonEmpty then
s"""REQUIRED type of your expression: $expectedType
|Your expression MUST type-check at this exact type. Any mismatch
|will be reported as a compile error and you will be asked to retry.""".stripMargin
else
"Type of your expression: not pinned at the call site."

private def contextSection(enclosingSource: String): String =
if enclosingSource.isEmpty then ""
else
s"""Placeholder marker: ${EvalContext.placeholder}
|Enclosing source:
|$enclosingSource""".stripMargin

private def bindingsSection(bindings: Array[Eval.Binding]): String =
if bindings.isEmpty then ""
else s"In-scope bindings: ${bindings.iterator.map(_.name).mkString(", ")}"

private def typeReminder(expectedType: String): String =
if expectedType.isEmpty then ""
else s"Reminder: the expression you produce must have type `$expectedType`."

private def errorSection(prevCode: String, prevErrors: List[String]): String =
if prevErrors.isEmpty then ""
else
s"""Previous attempt:
|$prevCode
|
|Previous attempt failed with:
|${prevErrors.mkString("\n")}
|
|Either fix the cause and emit a corrected expression, or — if the
|failure looks unrecoverable — emit code that throws a descriptive
|exception so the human user sees a clear cause. Output ONLY the
|expression.""".stripMargin

/** Strip ``` ... ``` fences around an LLM response if present. */
def stripCodeFences(s: String): String =
val t = s.trim
if t.startsWith("```") then
t.stripPrefix("```scala").stripPrefix("```").stripPrefix("\n").stripSuffix("```").trim
else t

end AgentPrompt
4 changes: 2 additions & 2 deletions library/impl/RealFileSystem.scala
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,12 @@ import java.nio.file.{Files, FileVisitResult, Path, Paths, SimpleFileVisitor}
import java.nio.file.attribute.BasicFileAttributes

class RealFileSystem(
val root: Path,
val root: String,
check: String -> Boolean = _ => true,
protected val classifiedPatterns: Set[String] = Set.empty
) extends BaseFileSystem:
protected val normalizedRoot: Path =
val abs = root.toAbsolutePath.normalize
val abs = Paths.get(root).toAbsolutePath.normalize
if Files.exists(abs) then abs.toRealPath() else abs
protected def pathCheck(relativePath: String): Boolean = check(relativePath)

Expand Down
4 changes: 2 additions & 2 deletions library/impl/VirtualFileSystem.scala
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@ import java.nio.charset.StandardCharsets
import java.nio.file.{Path, Paths}

class VirtualFileSystem(
val root: Path,
val root: String,
check: String -> Boolean = _ => true,
initialFiles: Map[String, String] = Map.empty,
protected val classifiedPatterns: Set[String] = Set.empty
) extends BaseFileSystem:
protected val normalizedRoot: Path = root.toAbsolutePath.normalize
protected val normalizedRoot: Path = Paths.get(root).toAbsolutePath.normalize
protected def pathCheck(relativePath: String): Boolean = check(relativePath)
private val files: TrieMap[Path, Array[Byte]] = TrieMap.empty
private val directories: TrieMap[Path, Unit] = TrieMap(normalizedRoot -> ())
Expand Down
4 changes: 2 additions & 2 deletions library/test/ClassifiedSuite.test.scala
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ class ClassifiedSuite extends munit.FunSuite:
LibraryConfig(strictMode = Some(false), classifiedPaths = Some(Set("secret")))
) {
def createFS(root: String, filter: String -> Boolean, classifiedPatterns: Set[String]): FileSystem =
new VirtualFileSystem(Path.of(root), filter, classifiedPatterns = classifiedPatterns)
new VirtualFileSystem(root, filter, classifiedPatterns = classifiedPatterns)
}.unsafeAssumePure

import interface.*
Expand Down Expand Up @@ -239,7 +239,7 @@ class ClassifiedSuite extends munit.FunSuite:
LibraryConfig(strictMode = Some(false), classifiedPaths = Some(patterns))
) {
def createFS(root: String, filter: String -> Boolean, classifiedPatterns: Set[String]): FileSystem =
new VirtualFileSystem(Path.of(root), filter, classifiedPatterns = classifiedPatterns)
new VirtualFileSystem(root, filter, classifiedPatterns = classifiedPatterns)
}.unsafeAssumePure

test("pattern without slash matches any component") {
Expand Down
Loading
Loading